diff --git a/app/tests/test_rdp_module_boot_setup.py b/app/tests/test_rdp_module_boot_setup.py new file mode 100644 index 0000000..bbd1f9c --- /dev/null +++ b/app/tests/test_rdp_module_boot_setup.py @@ -0,0 +1,74 @@ +import unittest +from pathlib import Path + + +RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix" + + +def _section(source: str, start: str, end: str) -> str: + start_idx = source.find(start) + if start_idx == -1: + raise AssertionError(f"Expected section start not found: {start!r}") + end_idx = source.find(end, start_idx) + if end_idx == -1: + raise AssertionError(f"Expected section end not found: {end!r}") + return source[start_idx:end_idx] + + +class RdpModuleBootSetupTests(unittest.TestCase): + def setUp(self): + self.source = RDP_NIX.read_text() + self.gnome_service = _section( + self.source, + "systemd.services.gnome-remote-desktop = {", + "systemd.tmpfiles.rules = [", + ) + self.setup_service = _section( + self.source, + "systemd.services.gnome-remote-desktop-setup = {", + "};\n}", + ) + + def test_does_not_redeclare_gnome_remote_desktop_user(self): + self.assertNotIn("users.users.gnome-remote-desktop", self.source) + self.assertNotIn("createHome = true;", self.source) + + def test_main_service_requires_setup_before_starting(self): + self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service) + self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service) + self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service) + + def test_setup_waits_for_configuration_service_and_bounded_timeout(self): + self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service) + self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service) + self.assertIn('"dbus.service"', self.setup_service) + self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service) + self.assertNotIn("RemainAfterExit", self.setup_service) + self.assertIn('TimeoutStartSec = "2min";', self.setup_service) + self.assertIn('timeout --kill-after=5s 15s', self.setup_service) + + def test_setup_runs_grdctl_as_gnome_remote_desktop_user(self): + self.assertIn("runuser -u gnome-remote-desktop -- \\", self.setup_service) + self.assertIn('grdctl --system "$@"', self.setup_service) + + def test_hub_files_are_the_source_of_truth_for_username_and_password(self): + self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service) + self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service) + self.assertIn("USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\"", self.setup_service) + self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service) + self.assertIn('PASSWORD="$(tr -d \'\\n\' < "$PASSWORD_FILE")"', self.setup_service) + self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service) + self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service) + + def test_secure_permissions_are_enforced_for_state_and_secret_files(self): + self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source) + self.assertIn('chmod 700 \\', self.setup_service) + self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service) + self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service) + self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service) + self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service) + self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service) + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/rdp.nix b/modules/rdp.nix old mode 100755 new mode 100644 index e8af84b..647d913 --- a/modules/rdp.nix +++ b/modules/rdp.nix @@ -2,70 +2,87 @@ lib.mkIf config.sovran_systemsOS.features.rdp { - users.users.gnome-remote-desktop = { - isSystemUser = true; - group = "gnome-remote-desktop"; - home = "/var/lib/gnome-remote-desktop"; - createHome = true; - }; - users.groups.gnome-remote-desktop = {}; - # Enable the GNOME Remote Desktop service at the system level services.gnome.gnome-remote-desktop.enable = true; # Open RDP port in the firewall networking.firewall.allowedTCPPorts = [ 3389 ]; - # Ensure the service actually starts and waits for setup to complete + # Ensure the service only starts after setup succeeds systemd.services.gnome-remote-desktop = { wantedBy = [ "graphical.target" ]; after = [ "gnome-remote-desktop-setup.service" ]; - wants = [ "gnome-remote-desktop-setup.service" ]; + requires = [ "gnome-remote-desktop-setup.service" ]; }; systemd.tmpfiles.rules = [ - "d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -" - "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -" + "d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -" ]; systemd.services.gnome-remote-desktop-setup = { description = "Configure GNOME Remote Desktop RDP"; - wantedBy = [ "multi-user.target" ]; + wantedBy = [ "graphical.target" ]; before = [ "gnome-remote-desktop.service" ]; - after = [ "systemd-tmpfiles-setup.service" "network-online.target" ]; - wants = [ "network-online.target" ]; + after = [ + "dbus.service" + "systemd-tmpfiles-setup.service" + "network-online.target" + "gnome-remote-desktop-configuration.service" + ]; + wants = [ + "network-online.target" + "gnome-remote-desktop-configuration.service" + ]; serviceConfig = { Type = "oneshot"; - RemainAfterExit = true; + TimeoutStartSec = "2min"; }; path = [ - pkgs.gnome-remote-desktop - pkgs.polkit - pkgs.openssl - pkgs.hostname + pkgs.coreutils pkgs.gawk + pkgs.gnome-remote-desktop + pkgs.hostname + pkgs.openssl + pkgs.polkit + pkgs.systemd + pkgs.util-linux ]; script = '' - # Ensure directory structure exists - mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop - chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop + set -euo pipefail - TLS_DIR="/var/lib/gnome-remote-desktop/tls" - CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials" + STATE_DIR="/var/lib/gnome-remote-desktop" + TLS_DIR="$STATE_DIR/tls" + USERNAME_FILE="$STATE_DIR/rdp-username" + PASSWORD_FILE="$STATE_DIR/rdp-password" + CRED_FILE="$STATE_DIR/rdp-credentials" + DEFAULT_USERNAME="sovran" + + grdctl_system() { + timeout --kill-after=5s 15s \ + runuser -u gnome-remote-desktop -- \ + grdctl --system "$@" + } + + mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR" + chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR" + chmod 700 \ + "$STATE_DIR" \ + "$STATE_DIR/.local" \ + "$STATE_DIR/.local/share" \ + "$STATE_DIR/.local/share/gnome-remote-desktop" \ + "$TLS_DIR" - # Regenerate TLS certificate if missing OR if ownership is wrong - # (disable/re-enable cycle can break ownership or grdctl state) NEED_REGEN=0 if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then NEED_REGEN=1 - elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then + elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then NEED_REGEN=1 fi if [ "$NEED_REGEN" = "1" ]; then - mkdir -p "$TLS_DIR" rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -sha256 -nodes -days 3650 \ @@ -75,39 +92,40 @@ lib.mkIf config.sovran_systemsOS.features.rdp { echo "Generated new RDP TLS certificate" fi - # Always fix ownership and permissions (handles re-enable after disable) - chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR" + chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" chmod 600 "$TLS_DIR/rdp-tls.key" chmod 644 "$TLS_DIR/rdp-tls.crt" - # Configure TLS certificate - grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt" - grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key" - - # Generate password on first boot only - PASSWORD="" - if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then - PASSWORD=$(openssl rand -base64 16) - echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password - chmod 600 /var/lib/gnome-remote-desktop/rdp-password - else - PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password) + if [ ! -f "$USERNAME_FILE" ]; then + printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE" fi + USERNAME="$(tr -d '\n' < "$USERNAME_FILE")" + if [ -z "$USERNAME" ]; then + USERNAME="$DEFAULT_USERNAME" + printf '%s\n' "$USERNAME" > "$USERNAME_FILE" + fi + chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE" + chmod 600 "$USERNAME_FILE" - # Write username to a separate file for the hub - echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username - chmod 600 /var/lib/gnome-remote-desktop/rdp-username + if [ ! -f "$PASSWORD_FILE" ]; then + openssl rand -base64 16 > "$PASSWORD_FILE" + fi + PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")" + if [ -z "$PASSWORD" ]; then + echo "RDP password file is empty: $PASSWORD_FILE" >&2 + exit 1 + fi + chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE" + chmod 600 "$PASSWORD_FILE" - # Get current IP address - LOCAL_IP=$(hostname -I | awk '{print $1}') + LOCAL_IP="$(hostname -I | awk '{print $1}')" - # Always rewrite the credentials file with the current IP cat > "$CRED_FILE" <