From 181f63dbd609f509977f68ae8cb6ca7223d06157 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 15:23:40 -0400 Subject: [PATCH 01/19] Lint the bootstrap scripts and build the Bicep templates in CI The host script job now runs bash -n and shellcheck (errors only) over custom_script_extensions/*.sh as well as linux_host, and a new job compiles deploy/bicep/main.bicep. Changes under either folder now trigger the workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/front-end-tests.yml | 21 +++++++++++++++++++++ linux_host/tests/run.sh | 12 ++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/front-end-tests.yml b/.github/workflows/front-end-tests.yml index c6b4a0a..ce74a69 100644 --- a/.github/workflows/front-end-tests.yml +++ b/.github/workflows/front-end-tests.yml @@ -8,6 +8,8 @@ on: - 'task/**' - 'sql_queries/**' - 'linux_host/**' + - 'custom_script_extensions/**' + - 'deploy/bicep/**' - '.github/workflows/front-end-tests.yml' push: paths: @@ -16,6 +18,8 @@ on: - 'task/**' - 'sql_queries/**' - 'linux_host/**' + - 'custom_script_extensions/**' + - 'deploy/bicep/**' - '.github/workflows/front-end-tests.yml' jobs: @@ -190,9 +194,26 @@ jobs: steps: - uses: actions/checkout@v4 + # Also lints the bootstrap scripts in custom_script_extensions. - name: Run the host script tests run: bash linux_host/tests/run.sh + bicep-build: + name: Bicep templates + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + # Compile only. main.json is regenerated with the template changes, and its exact + # contents depend on the Bicep version, so it is not compared here. + - name: Build the Bicep templates + run: | + az bicep install + az bicep build --file deploy/bicep/main.bicep --stdout > /dev/null + task-test: name: Scheduled task function runs-on: ubuntu-latest diff --git a/linux_host/tests/run.sh b/linux_host/tests/run.sh index 2ea68fa..6fea969 100644 --- a/linux_host/tests/run.sh +++ b/linux_host/tests/run.sh @@ -31,17 +31,25 @@ install_deps() { fi } +# The host scripts, plus the bootstrap scripts the Custom Script Extension runs. +lint_targets() { + find "$ROOT_DIR/linux_host" -type f -name '*.sh' + if [ -d "$ROOT_DIR/custom_script_extensions" ]; then + find "$ROOT_DIR/custom_script_extensions" -type f -name '*.sh' + fi +} + syntax_check() { local file while IFS= read -r file; do bash -n "$file" - done < <(find "$ROOT_DIR/linux_host" -type f -name '*.sh' | sort) + done < <(lint_targets | sort) if command -v shellcheck >/dev/null 2>&1; then while IFS= read -r file; do shellcheck --severity=error "$file" - done < <(find "$ROOT_DIR/linux_host" -type f -name '*.sh' | sort) + done < <(lint_targets | sort) else echo "shellcheck not available; skipping optional lint" fi From 9cace66ef1b7c030223090d2049923c0f6f483e5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 15:26:16 -0400 Subject: [PATCH 02/19] Retire RHEL 7 as a Linux host image RHEL 7 left maintenance in June 2024. Remove 7-LVM from the Bicep templates and delete Configure-RHEL7-Host.sh. Existing RHEL 7 hosts keep working, and patch-host.sh and the host migration still support yum. DEPLOYMENT.md explains what an environment that stores 7-LVM must change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../Configure-RHEL7-Host.sh | 345 ------------------ deploy/DEPLOYMENT.md | 13 +- deploy/bicep/main.bicep | 1 - deploy/bicep/main.json | 21 +- deploy/bicep/main.resources.bicep | 1 - deploy/bicep/modules/Linux/main.bicep | 15 +- 7 files changed, 15 insertions(+), 383 deletions(-) delete mode 100644 custom_script_extensions/Configure-RHEL7-Host.sh diff --git a/README.md b/README.md index 107de69..f8f8586 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The custom script extension for the AVD host: The custom script extensions support the following Linux distributions: -- **Red Hat Enterprise Linux (RHEL) 7, 8, and 9** +- **Red Hat Enterprise Linux (RHEL) 8 and 9** - **Ubuntu 24 Desktop** These scripts: diff --git a/custom_script_extensions/Configure-RHEL7-Host.sh b/custom_script_extensions/Configure-RHEL7-Host.sh deleted file mode 100644 index 5a6b4a0..0000000 --- a/custom_script_extensions/Configure-RHEL7-Host.sh +++ /dev/null @@ -1,345 +0,0 @@ -#!/bin/bash - -# Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 7 - -LINUXBROKER_API_BASE_URL="${1:-}" -LINUXBROKER_API_CLIENT_ID="${2:-}" - -if [ -z "$LINUXBROKER_API_BASE_URL" ] || [ -z "$LINUXBROKER_API_CLIENT_ID" ]; then - echo "Linux Broker API base URL and client ID are required." - exit 1 -fi - -case "$LINUXBROKER_API_BASE_URL" in - https://*) ;; - *) - echo "Linux Broker API base URL must start with https://" - exit 1 - ;; -esac - -LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" - -# =============================== -# Variables - -epel_url="https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm" -xpra_repo_path="/etc/yum.repos.d/xpra.repo" -xpra_url="https://xpra.org/repos/CentOS/xpra.repo" -microsoft_packages_url="https://packages.microsoft.com/config/rhel/7/packages-microsoft-prod.rpm" -# Override for sovereign or air-gapped clouds where raw.githubusercontent.com is unreachable. -script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main}" -script_source_root="${script_source_root%/}" - -release_session_url="$script_source_root/linux_host/session_release_buffer/RHEL/release-session.sh" -xrdp_who_xorg_url="$script_source_root/linux_host/session_release_buffer/xrdp-who-xorg.sh" -logind_watcher_url="$script_source_root/linux_host/session_release_buffer/logind-session-watcher.sh" -create_user_script_url="$script_source_root/linux_host/create-user.sh" -create_user_script="/usr/local/bin/create-user.sh" -manage_lease_script_url="$script_source_root/linux_host/manage-lease.sh" -manage_lease_script="/usr/local/bin/manage-lease.sh" -apply_settings_script_url="$script_source_root/linux_host/apply-host-settings.sh" -apply_settings_script="/usr/local/bin/apply-host-settings.sh" -session_control_script_url="$script_source_root/linux_host/session-control.sh" -session_control_script="/usr/local/bin/session-control.sh" -patch_host_script_url="$script_source_root/linux_host/patch-host.sh" -patch_host_script="/usr/local/bin/patch-host.sh" - -arch=$( /bin/arch ) -remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" - -# Disable the GNOME screen saver and screen lock on this host. Enabled by default because a -# locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which -# strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. -disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" -disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') - -case "$disableScreenLock" in - true|1|yes|y) disableScreenLock="true" ;; - false|0|no|n) disableScreenLock="false" ;; - *) - echo "Unsupported LINUXBROKER_DISABLE_SCREEN_LOCK value: $disableScreenLock (expected true or false)" - exit 1 - ;; -esac - -orgId="${RHEL_ORG_ID:-}" -activationKey="${RHEL_ACTIVATION_KEY:-}" - -output_directory="/usr/local/bin" -state_directory="/var/lib/linuxbroker-release-session" - -SCRIPT_PATH="$output_directory/release-session.sh" -WATCHER_SCRIPT_PATH="$output_directory/logind-session-watcher.sh" -LOG_FILE="/var/log/release-session.log" -CURRENT_USERS_DETAILS="$state_directory/current_users.txt" -PREVIOUS_USERS_FILE="$state_directory/previous_users.txt" -DISCONNECTED_USERS_FILE="$state_directory/disconnected_users.tsv" -SYSTEMD_SERVICE_NAME="linuxbroker-release-session.service" -SYSTEMD_TIMER_NAME="linuxbroker-release-session.timer" -WATCHER_SERVICE_NAME="linuxbroker-release-session-watcher.service" -SYSTEMD_SERVICE_PATH="/etc/systemd/system/$SYSTEMD_SERVICE_NAME" -SYSTEMD_TIMER_PATH="/etc/systemd/system/$SYSTEMD_TIMER_NAME" -WATCHER_SERVICE_PATH="/etc/systemd/system/$WATCHER_SERVICE_NAME" - -YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" -YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" - -# =============================== -# Execution - -if [ -n "$orgId" ] && [ -n "$activationKey" ]; then - echo "Registering the system..." - sudo subscription-manager register --org="$orgId" --activationkey="$activationKey" - sudo subscription-manager repos --enable="rhel-7-server-optional-rpms" --enable="rhel-7-server-extras-rpms" --enable="rhel-7-server-rh-common-rpms" -else - echo "Skipping system registration." -fi - -echo "Updating and upgrading system packages..." -sudo yum update -y - -sudo yum install -y "$epel_url" -sudo yum install -y "$microsoft_packages_url" -sudo wget -O "$xpra_repo_path" "$xpra_url" -sudo yum install -y wget util-linux azure-cli nfs-utils xorgxrdp curl jq dconf - -# Idle session enforcement degrades gracefully without xprintidle, so a host that cannot -# install it must still finish provisioning rather than fail the extension. -echo "Installing idle detection support..." -sudo yum install -y xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." -sudo yum groupinstall -y "Server with GUI" - -case "$remoteAccessTool" in - "xrdp") - remoteAccessPackages=("xrdp") - ;; - "xpra") - remoteAccessPackages=("xpra") - ;; - "both") - remoteAccessPackages=("xrdp" "xpra") - ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 - ;; -esac - -for pkg in "${remoteAccessPackages[@]}"; do - sudo yum install -y "$pkg" -done - -echo "Setting default target to graphical..." -sudo systemctl set-default graphical.target - -echo "Starting graphical target..." -sudo systemctl start graphical.target - -if sudo systemctl is-active --quiet firewalld; then - echo "Firewalld is already active." -else - echo "Enabling and starting firewalld..." - sudo systemctl enable --now firewalld -fi - -echo "Configuring firewall to allow $remoteAccessTool connections..." -sudo firewall-cmd --permanent --add-port=22/tcp # Always allow SSH - -if [ "$remoteAccessTool" = "xrdp" ] || [ "$remoteAccessTool" = "both" ]; then - sudo firewall-cmd --permanent --add-port=3389/tcp - sudo firewall-cmd --permanent --add-port=443/tcp - if sudo systemctl is-active --quiet xrdp; then - echo "xrdp service is already active." - else - echo "Starting and enabling xrdp service..." - sudo systemctl start xrdp - sudo systemctl enable xrdp --now - fi -fi - -if [ "$remoteAccessTool" = "xpra" ] || [ "$remoteAccessTool" = "both" ]; then - sudo firewall-cmd --permanent --add-port=443/tcp - if sudo systemctl is-active --quiet xpra; then - echo "xpra service is already active." - else - echo "Starting and enabling xpra service..." - sudo systemctl start xpra - sudo systemctl enable xpra --now - fi -fi - -sudo firewall-cmd --reload -echo "Firewall configuration completed." - -if [ ! -d "$output_directory" ]; then - sudo mkdir -p "$output_directory" - echo "Directory $output_directory created." -fi - -echo "Downloading release-session.sh..." -sudo wget -O "$SCRIPT_PATH" "$release_session_url" - -sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" - -echo "Downloading xrdp-who-xorg.sh..." -sudo wget -O "$output_directory/xrdp-who-xorg.sh" "$xrdp_who_xorg_url" - -echo "Downloading logind-session-watcher.sh..." -sudo wget -O "$WATCHER_SCRIPT_PATH" "$logind_watcher_url" - -echo "Downloading create-user.sh..." -sudo wget -O "$create_user_script" "$create_user_script_url" - -echo "Downloading manage-lease.sh..." -sudo wget -O "$manage_lease_script" "$manage_lease_script_url" - -echo "Downloading apply-host-settings.sh..." -sudo wget -O "$apply_settings_script" "$apply_settings_script_url" - -echo "Downloading session-control.sh..." -sudo wget -O "$session_control_script" "$session_control_script_url" - -echo "Downloading patch-host.sh..." -sudo wget -O "$patch_host_script" "$patch_host_script_url" - -sudo chmod +x "$SCRIPT_PATH" -sudo chmod +x "$output_directory/xrdp-who-xorg.sh" -sudo chmod +x "$WATCHER_SCRIPT_PATH" -sudo chmod +x "$create_user_script" -sudo chmod +x "$manage_lease_script" -sudo chmod +x "$apply_settings_script" -sudo chmod +x "$session_control_script" -sudo chmod +x "$patch_host_script" -echo "Downloaded scripts are now executable." - -sudo mkdir -p "$state_directory" -sudo touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" -sudo chown root:root "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" -sudo chmod 600 "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" - -echo "Removing legacy cron entry for release-session.sh..." -tmp_cron=$(mktemp) -sudo crontab -l 2>/dev/null | grep -v -F "$SCRIPT_PATH" > "$tmp_cron" || true -if [ -s "$tmp_cron" ]; then - sudo crontab "$tmp_cron" -else - sudo crontab -r 2>/dev/null || true -fi -rm -f "$tmp_cron" - -echo "Stopping any legacy release-session.sh processes..." -sudo pkill -f "$SCRIPT_PATH" || true - -echo "Installing systemd service for release-session.sh..." -cat </dev/null -[Unit] -Description=Linux Broker Release Agent -After=network-online.target xrdp.service -Wants=network-online.target -ConditionPathExists=$SCRIPT_PATH - -[Service] -Type=oneshot -User=root -WorkingDirectory=$state_directory -ExecStart=$SCRIPT_PATH --systemd-timer -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -EOF - -echo "Installing systemd timer for release-session.sh..." -cat </dev/null -[Unit] -Description=Run Linux Broker Release Agent every minute - -[Timer] -OnBootSec=1min -OnUnitActiveSec=1min -AccuracySec=1s -Persistent=true -Unit=$SYSTEMD_SERVICE_NAME - -[Install] -WantedBy=timers.target -EOF - -echo "Installing systemd service for logind-session-watcher.sh..." -cat </dev/null -[Unit] -Description=Linux Broker logind Session Watcher -After=network-online.target systemd-logind.service -Wants=network-online.target -ConditionPathExists=$WATCHER_SCRIPT_PATH - -[Service] -Type=simple -User=root -WorkingDirectory=$state_directory -ExecStart=$WATCHER_SCRIPT_PATH -Restart=always -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -EOF - -echo "Reloading systemd and enabling release-session timer..." -sudo systemctl disable --now "$WATCHER_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl disable --now "$SYSTEMD_TIMER_NAME" >/dev/null 2>&1 || true -sudo systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl daemon-reload -sudo systemctl reset-failed "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl reset-failed "$WATCHER_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl enable --now "$SYSTEMD_TIMER_NAME" -sudo systemctl enable --now "$WATCHER_SERVICE_NAME" -sudo systemctl start "$SYSTEMD_SERVICE_NAME" -echo "Systemd timer and logind watcher configured successfully." - -if ! id avdadmin >/dev/null 2>&1; then - sudo useradd avdadmin -fi - -# Only the commands the broker API actually invokes with sudo. Privileged file work -# (mount, chown, chmod, lease markers, host settings) happens inside the allowlisted -# scripts, each of which validates its own input. -cmds=(userdel groupadd usermod chpasswd "$create_user_script" "$manage_lease_script" "$apply_settings_script" "$session_control_script" "$patch_host_script") -full_paths=$(for cmd in "${cmds[@]}"; do command -v "$cmd"; done | paste -sd ',' -) -sudoers_tmp="/etc/sudoers.d/avdadmin.tmp" -echo "avdadmin ALL=(ALL) NOPASSWD: $full_paths" | sudo tee "$sudoers_tmp" >/dev/null -sudo chmod 440 "$sudoers_tmp" -if sudo visudo -c -f "$sudoers_tmp" >/dev/null 2>&1; then - sudo mv "$sudoers_tmp" /etc/sudoers.d/avdadmin -else - sudo rm -f "$sudoers_tmp" - echo "ERROR: Generated sudoers policy failed validation." - exit 1 -fi -echo "avdadmin user is created and permissioned" - -# Seed the Linux Broker host settings profile. This writes the dconf screen lock policy, -# the dconf profile that makes it take effect, the release agent's settings file, and the -# systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK still -# chooses the screen lock posture; from here on the values are managed from the portal and -# the release agent converges the host to the configured profile on its next run. -if [ "$disableScreenLock" = "true" ]; then - echo "Seeding host settings with the Gnome Desktop screen saver and screen lock disabled..." - settings_seed='{"ScreenLockEnabled":false,"DisableLockScreen":true}' -else - echo "Seeding host settings with the Gnome Desktop screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." - settings_seed='{"ScreenLockEnabled":true,"DisableLockScreen":false}' -fi - -if ! printf '%s' "$settings_seed" | sudo "$apply_settings_script"; then - echo "ERROR: Failed to apply the initial Linux Broker host settings." - exit 1 -fi - -echo "System configuration complete." diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index b24db86..390bb1f 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -100,7 +100,7 @@ The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.exampl - `avdSessionHostCount`: number of AVD hosts to provision. - `linuxHostVmSize`: Linux host VM size. - `avdVmSize`: AVD host VM size. -- `linuxHostOsVersion`: Linux image SKU. The RHEL options (`7-LVM`, `8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. +- `linuxHostOsVersion`: Linux image SKU. The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. - `linuxHostDisableScreenLock`: `true` or `false`. Disables the GNOME screen saver and screen lock on RHEL hosts. Defaults to `true`. See [Linux Host Screen Lock](#linux-host-screen-lock). - `azureCloudName`: `AzurePublic`, `AzureUSGovernment`, or `AzureCustom`. See [Choosing The Target Azure Cloud](#choosing-the-target-azure-cloud). - `scriptSourceRoot`: root URL the Linux host and AVD host bootstrap scripts are downloaded from. @@ -276,8 +276,8 @@ azd env set linuxHostDisableScreenLock false ``` The bootstrap then seeds the profile with the lock screen left enabled. You can also set -`LINUXBROKER_DISABLE_SCREEN_LOCK=false` in the environment if you run `Configure-RHEL7-Host.sh`, -`Configure-RHEL8-Host.sh`, or `Configure-RHEL9-Host.sh` by hand. +`LINUXBROKER_DISABLE_SCREEN_LOCK=false` in the environment if you run `Configure-RHEL8-Host.sh` +or `Configure-RHEL9-Host.sh` by hand. Because the values are part of the host settings profile, this posture can also be changed after deployment from **Host Settings** in the portal, without redeploying anything. @@ -553,6 +553,13 @@ This release completes the admin console: sessions and users, broadcast messages 3. On **Sessions**, send a message to one test session, then run a restart-only maintenance run over one idle host and confirm it comes back in service. Try **Security updates** on a single host before a larger run. Every layer tolerates the others being one release behind during the rollout. The previous API build keeps working against the new database: the changed procedures only add result columns, and scaling's normal call is unchanged. A portal that meets an older API hides the dashboard's trends and Attention panel, pages the host list itself, points the Scaling section at the scaling rules, and shows the new pages' errors. A task that meets an older API logs a `404` from the maintenance timer and carries on. + +## Upgrading To Distribution And Desktop Support + +This release changes which Linux distributions and desktops the deployment offers (items 3.1–3.4, 3.6 and 3.7 of the [roadmap](../docs/ROADMAP.md)). + +- **RHEL 7 is no longer offered.** `7-LVM` is removed from `linuxHostOsVersion`, along with `Configure-RHEL7-Host.sh`; RHEL 7 left maintenance on June 30, 2024. An azd environment that still stores `linuxHostOsVersion=7-LVM` fails template validation at the next `azd provision`, even with `deployLinuxHosts=false`, so set it to a supported value first. A VM's image cannot be changed in place, so for existing RHEL 7 hosts either also set `deployLinuxHosts=false`, which leaves them as they are, or replace them: drain them, delete the VMs in Azure and their records in the portal, and run `azd provision`. Existing RHEL 7 hosts keep working with the broker, and `patch-host.sh` and the host migration still support them. + ## Manual Steps After `azd up` ### Admin consent diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index fa136ff..7f2a4d3 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -143,7 +143,6 @@ param linuxHostAuthType string = 'SSH' param linuxHostSshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' '24_04-lts' diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 8a0da84..4b8c433 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8661975720375439198" + "templateHash": "10979517374697372728" } }, "parameters": { @@ -314,7 +314,6 @@ "type": "string", "defaultValue": "24_04-lts", "allowedValues": [ - "7-LVM", "8-LVM", "9-LVM", "24_04-lts" @@ -551,7 +550,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "1069093875186774374" + "templateHash": "11222893416813489555" } }, "parameters": { @@ -758,7 +757,6 @@ "type": "string", "defaultValue": "24_04-lts", "allowedValues": [ - "7-LVM", "8-LVM", "9-LVM", "24_04-lts" @@ -2841,7 +2839,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "15055457811505930858" + "templateHash": "2955371549082891553" } }, "parameters": { @@ -2899,7 +2897,6 @@ "OSVersion": { "type": "string", "allowedValues": [ - "7-LVM", "8-LVM", "9-LVM", "24_04-lts" @@ -2934,18 +2931,6 @@ "adminCredentials": "[if(equals(parameters('authType'), 'Password'), createObject('adminPassword', parameters('adminPassword')), createObject())]", "linuxConfiguration": "[if(equals(parameters('authType'), 'SSH'), createObject('disablePasswordAuthentication', true(), 'ssh', createObject('publicKeys', createArray(createObject('path', format('/home/{0}/.ssh/authorized_keys', parameters('adminUsername')), 'keyData', parameters('sshPublicKey'))))), createObject('disablePasswordAuthentication', false()))]", "imageConfigs": { - "7-LVM": { - "image": { - "publisher": "RedHat", - "offer": "RHEL", - "sku": "7lvm-gen2", - "version": "latest" - }, - "script": { - "uri": "[format('{0}/custom_script_extensions/Configure-RHEL7-Host.sh', variables('normalizedScriptSourceRoot'))]", - "cmd": "[format('{0} bash Configure-RHEL7-Host.sh {1}', variables('bootstrapEnv'), variables('bootstrapArgs'))]" - } - }, "8-LVM": { "image": { "publisher": "RedHat", diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 321f888..8023528 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -81,7 +81,6 @@ param linuxHostCount int = 0 param linuxHostAuthType string = 'SSH' param linuxHostSshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' '24_04-lts' diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index dcd9445..f2b6f5c 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -24,7 +24,6 @@ param adminPassword string param sshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' '24_04-lts' @@ -62,20 +61,8 @@ var linuxConfiguration = authType == 'SSH' } // The VMs below use Trusted Launch, which requires Generation 2 images. The RHEL SKUs named -// by OSVersion (7-LVM, 8-LVM, 9-LVM) are Generation 1, so each maps to its Gen2 equivalent. +// by OSVersion (8-LVM, 9-LVM) are Generation 1, so each maps to its Gen2 equivalent. var imageConfigs = { - '7-LVM': { - image: { - publisher: 'RedHat' - offer: 'RHEL' - sku: '7lvm-gen2' - version: 'latest' - } - script: { - uri: '${normalizedScriptSourceRoot}/custom_script_extensions/Configure-RHEL7-Host.sh' - cmd: '${bootstrapEnv} bash Configure-RHEL7-Host.sh ${bootstrapArgs}' - } - } '8-LVM': { image: { publisher: 'RedHat' From 9079012376fe6a91b3b353994cb718124d40ace1 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 15:27:15 -0400 Subject: [PATCH 03/19] Default new Linux hosts to RHEL 9 The previous default, 24_04-lts, deployed an Ubuntu server image with no desktop. New azd environments and templates deployed without a value now get 9-LVM; existing environments keep the value they stored. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- deploy/DEPLOYMENT.md | 3 ++- deploy/Initialize-DeploymentEnvironment.ps1 | 2 +- deploy/bicep/main.bicep | 2 +- deploy/bicep/main.json | 8 ++++---- deploy/bicep/main.parameters.example.json | 2 +- deploy/bicep/main.resources.bicep | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 390bb1f..ad85617 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -100,7 +100,7 @@ The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.exampl - `avdSessionHostCount`: number of AVD hosts to provision. - `linuxHostVmSize`: Linux host VM size. - `avdVmSize`: AVD host VM size. -- `linuxHostOsVersion`: Linux image SKU. The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. +- `linuxHostOsVersion`: Linux image SKU. Defaults to `9-LVM` (RHEL 9). The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. - `linuxHostDisableScreenLock`: `true` or `false`. Disables the GNOME screen saver and screen lock on RHEL hosts. Defaults to `true`. See [Linux Host Screen Lock](#linux-host-screen-lock). - `azureCloudName`: `AzurePublic`, `AzureUSGovernment`, or `AzureCustom`. See [Choosing The Target Azure Cloud](#choosing-the-target-azure-cloud). - `scriptSourceRoot`: root URL the Linux host and AVD host bootstrap scripts are downloaded from. @@ -558,6 +558,7 @@ Every layer tolerates the others being one release behind during the rollout. Th This release changes which Linux distributions and desktops the deployment offers (items 3.1–3.4, 3.6 and 3.7 of the [roadmap](../docs/ROADMAP.md)). +- **RHEL 9 is the default Linux host.** New azd environments, and templates deployed without a value, now use `linuxHostOsVersion=9-LVM` instead of `24_04-lts`, which deployed an Ubuntu server with no desktop. An existing environment keeps the value it stored; check it with `azd env get-value linuxHostOsVersion`. - **RHEL 7 is no longer offered.** `7-LVM` is removed from `linuxHostOsVersion`, along with `Configure-RHEL7-Host.sh`; RHEL 7 left maintenance on June 30, 2024. An azd environment that still stores `linuxHostOsVersion=7-LVM` fails template validation at the next `azd provision`, even with `deployLinuxHosts=false`, so set it to a supported value first. A VM's image cannot be changed in place, so for existing RHEL 7 hosts either also set `deployLinuxHosts=false`, which leaves them as they are, or replace them: drain them, delete the VMs in Azure and their records in the portal, and run `azd provision`. Existing RHEL 7 hosts keep working with the broker, and `patch-host.sh` and the host migration still support them. ## Manual Steps After `azd up` diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index 72ccaf9..6ac3cdf 100644 --- a/deploy/Initialize-DeploymentEnvironment.ps1 +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -1054,7 +1054,7 @@ Ensure-DefaultEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY' -ValueFactory { '' } | O Ensure-DefaultEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' -ValueFactory { '' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostSshPublicKey' -ValueFactory { Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PUBLIC_KEY' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostSshPrivateKey' -ValueFactory { Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' } | Out-Null -Ensure-DefaultEnvValue -Key 'linuxHostOsVersion' -ValueFactory { '24_04-lts' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostOsVersion' -ValueFactory { '9-LVM' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostDisableScreenLock' -ValueFactory { 'true' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostVmSize' -ValueFactory { 'Standard_D2s_v5' } | Out-Null Ensure-DefaultEnvValue -Key 'avdVmSize' -ValueFactory { 'Standard_D8s_v5' } | Out-Null diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 7f2a4d3..2efbe07 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -148,7 +148,7 @@ param linuxHostSshPublicKey string = '' '24_04-lts' ]) @description('Linux host OS image SKU.') -param linuxHostOsVersion string = '24_04-lts' +param linuxHostOsVersion string = '9-LVM' @description('Disable the GNOME screen saver and screen lock on RHEL hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control. Has no effect on the Ubuntu server image, which has no desktop.') param linuxHostDisableScreenLock bool = true diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 4b8c433..0a4c721 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "10979517374697372728" + "templateHash": "18396639136736869414" } }, "parameters": { @@ -312,7 +312,7 @@ }, "linuxHostOsVersion": { "type": "string", - "defaultValue": "24_04-lts", + "defaultValue": "9-LVM", "allowedValues": [ "8-LVM", "9-LVM", @@ -550,7 +550,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "11222893416813489555" + "templateHash": "14380665631401972090" } }, "parameters": { @@ -755,7 +755,7 @@ }, "linuxHostOsVersion": { "type": "string", - "defaultValue": "24_04-lts", + "defaultValue": "9-LVM", "allowedValues": [ "8-LVM", "9-LVM", diff --git a/deploy/bicep/main.parameters.example.json b/deploy/bicep/main.parameters.example.json index 0feb423..364c988 100644 --- a/deploy/bicep/main.parameters.example.json +++ b/deploy/bicep/main.parameters.example.json @@ -123,7 +123,7 @@ "value": "SSH" }, "linuxHostOsVersion": { - "value": "24_04-lts" + "value": "9-LVM" }, "linuxHostDisableScreenLock": { "value": true diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 8023528..06d93c3 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -85,7 +85,7 @@ param linuxHostSshPublicKey string = '' '9-LVM' '24_04-lts' ]) -param linuxHostOsVersion string = '24_04-lts' +param linuxHostOsVersion string = '9-LVM' @description('Disable the GNOME screen saver and screen lock on RHEL hosts. Set to false to keep the lock screen.') param linuxHostDisableScreenLock bool = true From 807c4c3380a7dec4ecbe9482cc3e98220dfa37e8 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 15:30:38 -0400 Subject: [PATCH 04/19] Merge the RHEL and Ubuntu release agents into one script The two copies of release-session.sh differed only in ensure_jq_installed and the RHEL-only orphaned NFS home cleanup. The merged script installs jq with apt-get, dnf or yum, and unmounts orphaned homes on every distribution. The CSEs, the host migration and the tests use the new path, and the unused xrdp-who-xnc.sh is deleted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Configure-RHEL8-Host.sh | 2 +- .../Configure-RHEL9-Host.sh | 2 +- .../Configure-Ubuntu24_desktop-Host.sh | 2 +- deploy/DEPLOYMENT.md | 1 + deploy/Migrate-LinuxHostReleaseAgent.ps1 | 18 +- .../Ubuntu/release-session.sh | 1120 ----------------- .../{RHEL => }/release-session.sh | 31 +- .../session_release_buffer/xrdp-who-xnc.sh | 58 - linux_host/tests/test_heartbeat.sh | 3 +- linux_host/tests/test_release_session.sh | 45 +- 10 files changed, 78 insertions(+), 1204 deletions(-) delete mode 100644 linux_host/session_release_buffer/Ubuntu/release-session.sh rename linux_host/session_release_buffer/{RHEL => }/release-session.sh (97%) delete mode 100644 linux_host/session_release_buffer/xrdp-who-xnc.sh diff --git a/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index b4d6130..6961053 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -48,7 +48,7 @@ microsoft_packages_url="https://packages.microsoft.com/config/rhel/8/packages-mi script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/$GH_OWNER/$GH_REPO/refs/heads/$GH_BRANCH}" script_source_root="${script_source_root%/}" -release_session_url="$script_source_root/linux_host/session_release_buffer/RHEL/release-session.sh" +release_session_url="$script_source_root/linux_host/session_release_buffer/release-session.sh" xrdp_who_xorg_url="$script_source_root/linux_host/session_release_buffer/xrdp-who-xorg.sh" logind_watcher_url="$script_source_root/linux_host/session_release_buffer/logind-session-watcher.sh" create_user_script_url="$script_source_root/linux_host/create-user.sh" diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index 7d32724..1ec3234 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -29,7 +29,7 @@ microsoft_packages_url="https://packages.microsoft.com/config/rhel/9/packages-mi script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/main}" script_source_root="${script_source_root%/}" -release_session_url="$script_source_root/linux_host/session_release_buffer/RHEL/release-session.sh" +release_session_url="$script_source_root/linux_host/session_release_buffer/release-session.sh" xrdp_who_xorg_url="$script_source_root/linux_host/session_release_buffer/xrdp-who-xorg.sh" logind_watcher_url="$script_source_root/linux_host/session_release_buffer/logind-session-watcher.sh" create_user_script_url="$script_source_root/linux_host/create-user.sh" diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index 8d1998a..3d62801 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -24,7 +24,7 @@ LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/main}" script_source_root="${script_source_root%/}" -release_session_url="$script_source_root/linux_host/session_release_buffer/Ubuntu/release-session.sh" +release_session_url="$script_source_root/linux_host/session_release_buffer/release-session.sh" xrdp_who_xorg_url="$script_source_root/linux_host/session_release_buffer/xrdp-who-xorg.sh" logind_watcher_url="$script_source_root/linux_host/session_release_buffer/logind-session-watcher.sh" create_user_script_url="$script_source_root/linux_host/create-user.sh" diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index ad85617..2fae40f 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -560,6 +560,7 @@ This release changes which Linux distributions and desktops the deployment offer - **RHEL 9 is the default Linux host.** New azd environments, and templates deployed without a value, now use `linuxHostOsVersion=9-LVM` instead of `24_04-lts`, which deployed an Ubuntu server with no desktop. An existing environment keeps the value it stored; check it with `azd env get-value linuxHostOsVersion`. - **RHEL 7 is no longer offered.** `7-LVM` is removed from `linuxHostOsVersion`, along with `Configure-RHEL7-Host.sh`; RHEL 7 left maintenance on June 30, 2024. An azd environment that still stores `linuxHostOsVersion=7-LVM` fails template validation at the next `azd provision`, even with `deployLinuxHosts=false`, so set it to a supported value first. A VM's image cannot be changed in place, so for existing RHEL 7 hosts either also set `deployLinuxHosts=false`, which leaves them as they are, or replace them: drain them, delete the VMs in Azure and their records in the portal, and run `azd provision`. Existing RHEL 7 hosts keep working with the broker, and `patch-host.sh` and the host migration still support them. +- **One release agent for every distribution.** The separate RHEL and Ubuntu copies of `release-session.sh` are merged into `linux_host/session_release_buffer/release-session.sh`, and the unused `xrdp-who-xnc.sh` is deleted. Ubuntu hosts now also unmount orphaned NFS homes, as RHEL hosts did. Run [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release: a copy from an earlier release downloads the old paths, which no longer exist, and stops before it changes anything. ## Manual Steps After `azd up` diff --git a/deploy/Migrate-LinuxHostReleaseAgent.ps1 b/deploy/Migrate-LinuxHostReleaseAgent.ps1 index 66ce43d..e1b4344 100644 --- a/deploy/Migrate-LinuxHostReleaseAgent.ps1 +++ b/deploy/Migrate-LinuxHostReleaseAgent.ps1 @@ -290,22 +290,8 @@ else exit 1 fi -release_variant='RHEL' -case "${ID:-}" in - ubuntu|debian) - release_variant='Ubuntu' - ;; - rhel|almalinux|centos|rocky) - release_variant='RHEL' - ;; - *) - if [[ "${ID_LIKE:-}" == *'debian'* ]]; then - release_variant='Ubuntu' - fi - ;; -esac - -release_script_url="$script_source_root/linux_host/session_release_buffer/${release_variant}/release-session.sh" +# One release agent serves every distribution. +release_script_url="$script_source_root/linux_host/session_release_buffer/release-session.sh" xorg_script_url="$script_source_root/linux_host/session_release_buffer/xrdp-who-xorg.sh" watcher_script_url="$script_source_root/linux_host/session_release_buffer/logind-session-watcher.sh" create_user_script_url="$script_source_root/linux_host/create-user.sh" diff --git a/linux_host/session_release_buffer/Ubuntu/release-session.sh b/linux_host/session_release_buffer/Ubuntu/release-session.sh deleted file mode 100644 index 7de352a..0000000 --- a/linux_host/session_release_buffer/Ubuntu/release-session.sh +++ /dev/null @@ -1,1120 +0,0 @@ -#!/bin/bash - -# Support for Ubuntu systems - -export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - -# The Linux Broker host agent version. Every script in linux_host/ declares the same value -# and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" - -LOG_FILE="/var/log/release-session.log" -LOCATION_PATH="/usr/local/bin" -XORG_USERS_INFO_SCRIPT="$LOCATION_PATH/xrdp-who-xorg.sh" -APPLY_SETTINGS_SCRIPT="$LOCATION_PATH/apply-host-settings.sh" -SETTINGS_FILE="/etc/linuxbroker/host-settings.conf" -STATE_DIRECTORY="/var/lib/linuxbroker-release-session" -LEASE_DIRECTORY="$STATE_DIRECTORY/leases" -CURRENT_USERS_DETAILS="$STATE_DIRECTORY/current_users.txt" -PREVIOUS_USERS_FILE="$STATE_DIRECTORY/previous_users.txt" -DISCONNECTED_USERS_FILE="$STATE_DIRECTORY/disconnected_users.tsv" -IDLE_WARNED_USERS_FILE="$STATE_DIRECTORY/idle_warned_users.tsv" -ACKED_VERSION_FILE="$STATE_DIRECTORY/acked_settings_version" -LOCK_FILE="$STATE_DIRECTORY/reconcile.lock" -hostname=$(hostname) -LOCK_FD="" - -# Defaults matching the seeded profile in -# sql_queries/028_create_table-linux_host_settings.sql. They apply only until the first -# successful settings fetch, so a host that has never reached the broker behaves exactly as -# it did before these settings became configurable. -GRACE_PERIOD_SECONDS=1200 -IDLE_TIMEOUT_SECONDS=0 -IDLE_WARNING_SECONDS=120 -SETTINGS_VERSION=0 -PRESERVE_SESSIONS_ON_DISCONNECT=false - -RUN_MODE="manual" - -for arg in "$@"; do - case "$arg" in - --systemd-timer) - RUN_MODE="systemd-timer" - ;; - --logind-watcher) - RUN_MODE="logind-watcher" - ;; - --cron) - RUN_MODE="cron" - ;; - esac -done - -log() { - echo "$(date '+%Y-%m-%d %H:%M:%S') - [$RUN_MODE] - $1" | tee -a "$LOG_FILE" -} - -ensure_state_files() { - mkdir -p "$STATE_DIRECTORY" - # Temporary files are written here so the final mv is an atomic rename, and API - # responses and lease state must not be readable by the users signed in to the host. - chmod 700 "$STATE_DIRECTORY" - touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" "$IDLE_WARNED_USERS_FILE" - chmod 600 "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" "$IDLE_WARNED_USERS_FILE" -} - -acquire_reconcile_lock() { - mkdir -p "$STATE_DIRECTORY" - exec {LOCK_FD}> "$LOCK_FILE" - - if ! flock -n "$LOCK_FD"; then - log "Another reconciliation run is already in progress. Skipping this invocation." - eval "exec ${LOCK_FD}>&-" - LOCK_FD="" - exit 0 - fi -} - -release_reconcile_lock() { - if [ -n "$LOCK_FD" ]; then - flock -u "$LOCK_FD" 2>/dev/null || true - eval "exec ${LOCK_FD}>&-" - LOCK_FD="" - fi -} - -ensure_jq_installed() { - if command -v jq >/dev/null 2>&1; then - return 0 - fi - - log "jq not found. Installing jq..." - sudo apt update -y && sudo apt install -y jq - - if [ $? -ne 0 ]; then - log "ERROR: Failed to install jq." - exit 1 - fi - - log "jq installed successfully." -} - -resolve_xrdp_users_info_script() { - if [ -x "$XORG_USERS_INFO_SCRIPT" ]; then - echo "$XORG_USERS_INFO_SCRIPT" - return 0 - fi - - return 1 -} - -array_contains() { - local needle="$1" - shift - local item - - for item in "$@"; do - if [ "$item" = "$needle" ]; then - return 0 - fi - done - - return 1 -} - -get_disconnect_timestamp() { - tsv_get "$DISCONNECTED_USERS_FILE" "$1" -} - -upsert_disconnect_timestamp() { - tsv_upsert "$DISCONNECTED_USERS_FILE" "$1" "$2" -} - -clear_disconnect_timestamp() { - tsv_clear "$DISCONNECTED_USERS_FILE" "$1" -} - -# Generic single-key-per-user TSV helpers, shared by the disconnect and idle-warning state -# files so both behave identically. -tsv_get() { - local file="$1" - local username="$2" - - [ -f "$file" ] || return 0 - - awk -F '\t' -v user="$username" '$1 == user {print $2; exit}' "$file" -} - -tsv_upsert() { - local file="$1" - local username="$2" - local value="$3" - local tmp_file - - touch "$file" - tmp_file="$STATE_DIRECTORY/$(basename "$file").$$.tmp" - awk -F '\t' -v user="$username" '$1 != user' "$file" > "$tmp_file" - printf '%s\t%s\n' "$username" "$value" >> "$tmp_file" - mv "$tmp_file" "$file" - chmod 600 "$file" -} - -tsv_clear() { - local file="$1" - local username="$2" - local tmp_file - - [ -f "$file" ] || return 0 - - tmp_file="$STATE_DIRECTORY/$(basename "$file").$$.tmp" - awk -F '\t' -v user="$username" '$1 != user' "$file" > "$tmp_file" - mv "$tmp_file" "$file" - chmod 600 "$file" -} - -# Reads the cached settings profile written by apply-host-settings.sh. The file only ever -# contains integers and booleans generated by that script, so sourcing it is safe. -load_settings() { - if [ ! -r "$SETTINGS_FILE" ]; then - return 0 - fi - - . "$SETTINGS_FILE" - - GRACE_PERIOD_SECONDS="${LINUXBROKER_GRACE_PERIOD_SECONDS:-$GRACE_PERIOD_SECONDS}" - IDLE_TIMEOUT_SECONDS="${LINUXBROKER_IDLE_TIMEOUT_SECONDS:-$IDLE_TIMEOUT_SECONDS}" - IDLE_WARNING_SECONDS="${LINUXBROKER_IDLE_WARNING_SECONDS:-$IDLE_WARNING_SECONDS}" - SETTINGS_VERSION="${LINUXBROKER_SETTINGS_VERSION:-$SETTINGS_VERSION}" - PRESERVE_SESSIONS_ON_DISCONNECT="${LINUXBROKER_PRESERVE_SESSIONS_ON_DISCONNECT:-$PRESERVE_SESSIONS_ON_DISCONNECT}" -} - -# Pull side of settings delivery. Every failure path here is non-fatal: reconciliation is -# the job that actually reclaims VMs, so it must never be blocked by a settings problem. -refresh_settings() { - local api_base_url="YOUR_LINUX_BROKER_API_BASE_URL" - local settings_url="$api_base_url/hosts/settings" - local ack_url="$api_base_url/hosts/$hostname/settings/ack" - local access_token - local response_file - local http_status - local fetched_version - - # Settings are only refreshed from the timer path. A watcher-mode run is a child of the - # watcher service, and applying settings can restart that unit, which would SIGTERM this - # process part-way through a reconcile. The timer path applies the change within one - # interval anyway, so nothing is lost by skipping it here. - if [ "$RUN_MODE" = "logind-watcher" ]; then - return 0 - fi - - if [ ! -x "$APPLY_SETTINGS_SCRIPT" ]; then - log "Settings apply script $APPLY_SETTINGS_SCRIPT is unavailable. Continuing with cached settings." - return 0 - fi - - if ! access_token=$(get_access_token); then - log "Unable to obtain an access token for the settings fetch. Continuing with cached settings." - return 0 - fi - - response_file="$STATE_DIRECTORY/settings-response.$$.json" - - http_status=$(/usr/bin/curl -s -m 20 -w "%{http_code}" -o "$response_file" -X GET "$settings_url" \ - -H "Authorization: Bearer $access_token" \ - -H "Content-Type: application/json") - - if ! [[ "$http_status" =~ ^2[0-9][0-9]$ ]]; then - log "Settings fetch returned HTTP $http_status. Continuing with cached settings." - rm -f "$response_file" - return 0 - fi - - fetched_version=$(/usr/bin/jq -r '.SettingsVersion // empty' "$response_file" 2>/dev/null) - - if ! [[ "$fetched_version" =~ ^[0-9]+$ ]]; then - log "Settings response did not contain a usable SettingsVersion. Continuing with cached settings." - rm -f "$response_file" - return 0 - fi - - if [ "$fetched_version" = "$SETTINGS_VERSION" ]; then - # Already applied. The acknowledgement is tracked separately because it happens after - # the version is written to disk; without this retry a single failed ack would leave - # the host reported as drifted forever even though it is fully converged. - if [ "$(read_acked_version)" != "$fetched_version" ]; then - acknowledge_settings "$ack_url" "$access_token" "$fetched_version" - fi - - rm -f "$response_file" - return 0 - fi - - log "Applying settings version $fetched_version (was $SETTINGS_VERSION)." - - if "$APPLY_SETTINGS_SCRIPT" < "$response_file" >> "$LOG_FILE" 2>&1; then - load_settings - acknowledge_settings "$ack_url" "$access_token" "$fetched_version" - else - log "ERROR: Failed to apply settings version $fetched_version. Continuing with cached settings." - fi - - rm -f "$response_file" -} - -read_acked_version() { - if [ -s "$ACKED_VERSION_FILE" ]; then - tr -d '\r\n' < "$ACKED_VERSION_FILE" - fi -} - -acknowledge_settings() { - local ack_url="$1" - local access_token="$2" - local applied_version="$3" - local http_status - - http_status=$(/usr/bin/curl -s -m 20 -w "%{http_code}" -o /dev/null -X POST "$ack_url" \ - -H "Authorization: Bearer $access_token" \ - -H "Content-Type: application/json" \ - -d "$(/usr/bin/jq -cn --argjson settingsVersion "$applied_version" '{settingsVersion: $settingsVersion}')") - - if [[ "$http_status" =~ ^2[0-9][0-9]$ ]]; then - # Recorded only on success, so a failed acknowledgement is retried on the next run. - printf '%s\n' "$applied_version" > "$ACKED_VERSION_FILE" - chmod 600 "$ACKED_VERSION_FILE" - log "Acknowledged settings version $applied_version." - else - log "Could not acknowledge settings version $applied_version (HTTP $http_status)." - fi -} - -xorg_processes_for_user() { - local username="$1" - - ps h -C Xorg -o pid=,user=,comm= 2>/dev/null | awk -v user="$username" '$2 == user {print $1 ":" $3}' -} - -xorg_processes_remaining() { - local username="$1" - - [ -n "$(xorg_processes_for_user "$username")" ] -} - -terminate_session_processes() { - local username="$1" - local found_process="false" - - while IFS=: read -r pid process_name; do - [ -z "$pid" ] && continue - - found_process="true" - log "$process_name PID for user $username: $pid" - - if kill -9 "$pid" 2>/dev/null; then - log "Terminated $process_name process $pid for user $username." - else - log "ERROR: Failed to terminate $process_name process $pid for user $username." - fi - done < <(xorg_processes_for_user "$username") - - if [ "$found_process" != "true" ]; then - log "No XRDP session process found for user $username." - fi -} - -get_access_token() { - local resource="api://YOUR_LINUX_BROKER_API_CLIENT_ID" - local imds_endpoint="http://169.254.169.254/metadata/identity/oauth2/token" - local api_version="2018-02-01" - local uri="$imds_endpoint?api-version=$api_version&resource=$resource" - - local headers="Metadata:true" - local access_token=$(/usr/bin/curl -s -m 10 --header "$headers" "$uri" | /usr/bin/jq -r '.access_token') - - if [ "$access_token" == "null" ] || [ -z "$access_token" ]; then - log "ERROR: Failed to obtain access token." - # Returning rather than exiting lets non-critical callers such as the settings fetch - # continue. release_vm still treats an empty token as fatal. - return 1 - fi - - echo "$access_token" -} - -get_current_lease_id() { - local username="$1" - local lease_file="$LEASE_DIRECTORY/$username.lease" - - if [ ! -f "$lease_file" ]; then - return 1 - fi - - tr -d '\r\n' < "$lease_file" -} - -release_vm() { - local username="$1" - local api_base_url="YOUR_LINUX_BROKER_API_BASE_URL" - local release_vm_url="$api_base_url/vms/$hostname/release" - local access_token - local lease_id="" - local request_body - local response_file - local http_status - local json_hostname - local json_lease_id - local release_status - local release_succeeded=1 - - access_token=$(get_access_token) - - if [ -z "$access_token" ]; then - log "ERROR: Unable to obtain access token." - exit 1 - fi - - if lease_id=$(get_current_lease_id "$username"); then - request_body=$(/usr/bin/jq -cn --arg username "$username" --arg leaseId "$lease_id" '{username: $username, leaseId: $leaseId}') - else - log "No lease marker found for user $username. Falling back to username-only release." - request_body=$(/usr/bin/jq -cn --arg username "$username" '{username: $username}') - fi - - response_file="$STATE_DIRECTORY/release-response.$$.json" - - http_status=$(/usr/bin/curl -s -w "%{http_code}" -o "$response_file" -X POST "$release_vm_url" \ - -H "Authorization: Bearer $access_token" \ - -H "Content-Type: application/json" \ - -d "$request_body") - - json_hostname=$(/usr/bin/jq -r '.Hostname // empty' "$response_file" 2>/dev/null) - json_lease_id=$(/usr/bin/jq -r '.LeaseId // empty' "$response_file" 2>/dev/null) - release_status=$(/usr/bin/jq -r '.ReleaseStatus // empty' "$response_file" 2>/dev/null) - - if [[ "$http_status" =~ ^2[0-9][0-9]$ ]]; then - if [ "$release_status" == "NoActiveAssignment" ]; then - log "INFO: VM $hostname already has no active assignment. Nothing to release for user $username." - release_succeeded=0 - elif [ "$json_hostname" != "$hostname" ]; then - log "ERROR: Release response returned Hostname '$json_hostname' but expected '$hostname'." - elif [ -n "$lease_id" ] && [ "$json_lease_id" != "$lease_id" ]; then - log "ERROR: Release response for $hostname returned LeaseId '$json_lease_id' but expected '$lease_id'." - else - log "INFO: Successfully released VM with Hostname: $hostname" - release_succeeded=0 - fi - elif [ "$http_status" == "409" ]; then - # The broker reassigned this host, so retrying cannot succeed. - log "INFO: Lease for user $username on $hostname is stale. Skipping further release attempts." - release_succeeded=0 - elif [ "$http_status" == "404" ]; then - log "ERROR: The broker does not recognize Hostname $hostname. Skipping further release attempts." - release_succeeded=0 - else - log "ERROR: Failed to release VM with Hostname: $hostname (HTTP Status: $http_status)" - fi - - cat "$response_file" >> "$LOG_FILE" - - if [ "$PRESERVE_SESSIONS_ON_DISCONNECT" != "true" ]; then - terminate_session_processes "$username" - else - log "PreserveSessionsOnDisconnect is enabled. Leaving Xorg processes for user $username running after release." - fi - - rm -f "$response_file" - - return "$release_succeeded" -} - -terminate_logind_sessions() { - local username="$1" - local session_ids - local session_id - - session_ids=$(loginctl list-sessions --no-legend 2>/dev/null | awk -v user="$username" '$3 == user {print $1}') - - if [ -z "$session_ids" ]; then - log "No logind sessions found for user $username." - return 0 - fi - - for session_id in $session_ids; do - if loginctl terminate-session "$session_id"; then - log "Logged off user $username session $session_id after grace period." - else - log "ERROR: Failed to log off user $username session $session_id." - fi - done -} - -reconcile_disconnected_user() { - local username="$1" - local disconnected_at="$2" - local now="$3" - local elapsed=$((now - disconnected_at)) - local remaining - - if [ "$elapsed" -ge "$GRACE_PERIOD_SECONDS" ]; then - log "User $username remained disconnected for $elapsed seconds. Terminating remaining sessions." - terminate_logind_sessions "$username" - terminate_session_processes "$username" - - if xorg_processes_remaining "$username"; then - log "ERROR: Xorg processes remain for user $username after grace-period cleanup. Keeping disconnect timestamp for retry." - return - fi - - clear_disconnect_timestamp "$username" - return - fi - - remaining=$((GRACE_PERIOD_SECONDS - elapsed)) - log "User $username is still disconnected. Grace period expires in $remaining seconds." -} - -# --------------------------------------------------------------------------- -# Idle session enforcement -# -# Disabled when IDLE_TIMEOUT_SECONDS is 0, which is the shipped default. -# -# Enforcement deliberately fails open. If idle time cannot be read for any reason the user -# is left alone, because wrongly disconnecting an active user is far worse than letting an -# idle one hold a VM for another poll. -# --------------------------------------------------------------------------- - -# Reading a session's idle time goes through its X server and its owner's home, either of -# which can hang: a wedged Xorg, or a hard-mounted NFS home that is unreachable. -SESSION_PROBE_TIMEOUT_SECONDS=5 - -get_session_display() { - local xorg_pid="$1" - - tr '\0' '\n' < "/proc/$xorg_pid/cmdline" 2>/dev/null | grep -m1 -E '^:[0-9]+$' -} - -get_session_xauthority() { - local xorg_pid="$1" - local auth_path - local xorg_cwd - - auth_path=$(tr '\0' '\n' < "/proc/$xorg_pid/cmdline" 2>/dev/null | awk '$0 == "-auth" { getline; print; exit }') - - if [ -z "$auth_path" ]; then - return 0 - fi - - # xrdp 0.9.x passes a bare ".Xauthority", which is only meaningful relative to the Xorg - # process's working directory (the session owner's home). This agent runs with its own - # WorkingDirectory, so the path has to be resolved here or every idle lookup would fail - # to open the display and idle enforcement would silently never fire. - if [ "${auth_path#/}" = "$auth_path" ]; then - # Resolving the path stats the user's home, which blocks while an NFS home is hung. - xorg_cwd=$(timeout "$SESSION_PROBE_TIMEOUT_SECONDS" readlink -f "/proc/$xorg_pid/cwd" 2>/dev/null) - - if [ -n "$xorg_cwd" ]; then - auth_path="$xorg_cwd/$auth_path" - fi - fi - - echo "$auth_path" -} - -get_session_idle_seconds() { - local xorg_pid="$1" - local display - local xauthority - local idle_milliseconds - - if ! command -v xprintidle >/dev/null 2>&1; then - return 1 - fi - - display=$(get_session_display "$xorg_pid") - if [ -z "$display" ]; then - return 1 - fi - - xauthority=$(get_session_xauthority "$xorg_pid") - - if [ -n "$xauthority" ]; then - idle_milliseconds=$(DISPLAY="$display" XAUTHORITY="$xauthority" timeout "$SESSION_PROBE_TIMEOUT_SECONDS" xprintidle 2>/dev/null) - else - idle_milliseconds=$(DISPLAY="$display" timeout "$SESSION_PROBE_TIMEOUT_SECONDS" xprintidle 2>/dev/null) - fi - - if ! [[ "$idle_milliseconds" =~ ^[0-9]+$ ]]; then - return 1 - fi - - echo $((idle_milliseconds / 1000)) -} - -warn_idle_user() { - local username="$1" - local xorg_pid="$2" - local remaining="$3" - local display - local xauthority - local user_id - local message="Your session has been idle and will be disconnected in $remaining seconds. Move the mouse or press a key to stay connected." - - display=$(get_session_display "$xorg_pid") - [ -z "$display" ] && return 1 - - xauthority=$(get_session_xauthority "$xorg_pid") - user_id=$(id -u "$username" 2>/dev/null) - - if command -v notify-send >/dev/null 2>&1 && [ -n "$user_id" ] && command -v runuser >/dev/null 2>&1; then - if DISPLAY="$display" XAUTHORITY="$xauthority" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$user_id/bus" \ - runuser -u "$username" -- notify-send "Idle session warning" "$message" >/dev/null 2>&1; then - return 0 - fi - fi - - if command -v xmessage >/dev/null 2>&1; then - DISPLAY="$display" XAUTHORITY="$xauthority" xmessage -timeout 30 "$message" >/dev/null 2>&1 & - return 0 - fi - - return 1 -} - -# Drops the client connection while leaving Xorg running, so the session survives and the -# user can reconnect inside the grace period. Only processes named xrdp that hold the -# session's display socket are terminated, which is the same signal xrdp-who-xorg.sh uses to -# decide whether a session is connected. -disconnect_session() { - local username="$1" - local xorg_pid="$2" - local display - local display_number - local pid - local process_name - local disconnected="false" - - display=$(get_session_display "$xorg_pid") - if [ -z "$display" ]; then - log "Could not determine the display for user $username. Skipping idle disconnect." - return 1 - fi - - display_number="${display#:}" - - while read -r pid; do - [ -z "$pid" ] && continue - [ "$pid" = "$xorg_pid" ] && continue - - process_name=$(ps -p "$pid" -o comm= 2>/dev/null | xargs) - if [ "$process_name" != "xrdp" ]; then - continue - fi - - if kill -TERM "$pid" 2>/dev/null; then - disconnected="true" - log "Disconnected idle xrdp connection $pid for user $username." - else - log "ERROR: Failed to disconnect xrdp connection $pid for user $username." - fi - done < <( - ss -xp 2>/dev/null \ - | grep -E "xrdp_display_${display_number}([^0-9]|$)" \ - | grep -oE 'pid=[0-9]+' \ - | cut -d= -f2 \ - | sort -u - ) - - if [ "$disconnected" != "true" ]; then - log "No xrdp connection process was found for user $username on display $display." - return 1 - fi - - return 0 -} - -enforce_idle_session() { - local username="$1" - local xorg_pid="$2" - local idle_seconds - local warn_threshold - local already_warned - - if [ "$IDLE_TIMEOUT_SECONDS" -le 0 ]; then - return 0 - fi - - if ! idle_seconds=$(get_session_idle_seconds "$xorg_pid"); then - log "Could not read idle time for user $username. Skipping idle enforcement." - return 0 - fi - - if [ "$idle_seconds" -ge "$IDLE_TIMEOUT_SECONDS" ]; then - log "User $username has been idle for $idle_seconds seconds. Disconnecting the session." - - if disconnect_session "$username" "$xorg_pid"; then - tsv_clear "$IDLE_WARNED_USERS_FILE" "$username" - fi - - return 0 - fi - - if [ "$IDLE_WARNING_SECONDS" -le 0 ]; then - return 0 - fi - - warn_threshold=$((IDLE_TIMEOUT_SECONDS - IDLE_WARNING_SECONDS)) - - if [ "$idle_seconds" -lt "$warn_threshold" ]; then - # The user is active again, so clear the marker and let a fresh warning be sent if - # they go idle later. Clearing it any earlier would defeat the dedup check below and - # re-warn on every single run for the whole warning window. - tsv_clear "$IDLE_WARNED_USERS_FILE" "$username" - return 0 - fi - - already_warned=$(tsv_get "$IDLE_WARNED_USERS_FILE" "$username") - if [ -n "$already_warned" ]; then - return 0 - fi - - if warn_idle_user "$username" "$xorg_pid" "$((IDLE_TIMEOUT_SECONDS - idle_seconds))"; then - tsv_upsert "$IDLE_WARNED_USERS_FILE" "$username" "$(date +%s)" - log "Warned user $username that the session is approaching the idle limit." - fi -} - -# --------------------------------------------------------------------------- -# Heartbeat -# -# One heartbeat per timer run tells the broker what this host looks like: agent and script -# versions, OS, desktop, xrdp, NFS, load, memory, disk and sessions. The portal's fleet health -# reports on it, and nothing is decided from it, so every failure here is logged and ignored: -# reconciliation must never depend on it. -# --------------------------------------------------------------------------- - -HEARTBEAT_SCRIPTS=(release-session.sh logind-session-watcher.sh xrdp-who-xorg.sh create-user.sh manage-lease.sh apply-host-settings.sh session-control.sh patch-host.sh) -HEARTBEAT_BACKOFF_SECONDS=900 - -# The version an installed script declares, so a host that was only partly migrated shows up. -script_version() { - local path="$1" - local version - - [ -r "$path" ] || return 1 - version=$(grep -m1 -E '^LINUXBROKER_AGENT_VERSION="[^"]+"$' "$path" 2>/dev/null | cut -d'"' -f2) - [ -n "$version" ] || return 1 - echo "$version" -} - -collect_script_versions() { - local name - local version - local versions='{}' - - for name in "${HEARTBEAT_SCRIPTS[@]}"; do - [ -e "$LOCATION_PATH/$name" ] || continue - - # A script that predates the version constant is reported as null. - if version=$(script_version "$LOCATION_PATH/$name"); then - versions=$(/usr/bin/jq -c --arg name "$name" --arg version "$version" '. + {($name): $version}' <<< "$versions") - else - versions=$(/usr/bin/jq -c --arg name "$name" '. + {($name): null}' <<< "$versions") - fi - done - - echo "$versions" -} - -detect_desktop() { - if command -v gnome-shell >/dev/null 2>&1; then - echo "gnome" - elif command -v xfce4-session >/dev/null 2>&1; then - echo "xfce" - elif command -v mate-session >/dev/null 2>&1; then - echo "mate" - elif command -v startplasma-x11 >/dev/null 2>&1; then - echo "kde" - else - echo "none" - fi -} - -detect_xrdp_version() { - local line - - command -v xrdp >/dev/null 2>&1 || return 1 - line=$(xrdp --version 2>/dev/null | head -n 1) - [[ "$line" =~ ([0-9]+(\.[0-9]+)+) ]] || return 1 - echo "${BASH_REMATCH[1]}" -} - -# Prints " ". Mounted NFS homes are checked with a bounded -# stat, because a hung share blocks forever. With none mounted, a TCP connection to the NFS -# server remembered from an earlier mount shows whether a new checkout could mount one. -check_nfs() { - local server_file="$STATE_DIRECTORY/nfs_server" - local mounts=0 - local reachable="null" - local server="" - local source - local mountpoint - - while read -r source mountpoint; do - [ -z "$mountpoint" ] && continue - mounts=$((mounts + 1)) - [ -z "$server" ] && server="${source%%:*}" - - if timeout 5 stat -f "$mountpoint" >/dev/null 2>&1; then - [ "$reachable" = "null" ] && reachable="true" - else - reachable="false" - fi - done < <(awk '$3 ~ /^nfs/ && $2 ~ /^\/home\/[^\/]+$/ {print $1, $2}' /proc/mounts 2>/dev/null) - - if [ "$mounts" -gt 0 ]; then - if [[ "$server" =~ ^[A-Za-z0-9._-]+$ ]]; then - printf '%s\n' "$server" > "$server_file" 2>/dev/null && chmod 600 "$server_file" 2>/dev/null - fi - elif [ -s "$server_file" ]; then - server=$(head -n 1 "$server_file" 2>/dev/null) - if [[ "$server" =~ ^[A-Za-z0-9._-]+$ ]]; then - if timeout 5 bash -c "exec 3<>/dev/tcp/$server/2049" 2>/dev/null; then - reachable="true" - else - reachable="false" - fi - fi - fi - - echo "$mounts $reachable" -} - -# One entry of the heartbeat's session list, from what the reconcile run already knows. -session_json() { - local username="$1" - local state="$2" - local start_time="$3" - local active_pid="$4" - local started="" - local disconnected_since - local idle="" - - if [ -n "$start_time" ]; then - started=$(date -d "$start_time" +%s 2>/dev/null || true) - fi - - disconnected_since=$(get_disconnect_timestamp "$username") - - if [ "$state" = "active" ] && [ -n "$active_pid" ]; then - idle=$(get_session_idle_seconds "$active_pid" 2>/dev/null || true) - fi - - /usr/bin/jq -cn \ - --arg username "$username" \ - --arg state "$state" \ - --arg started "$started" \ - --arg disconnectedSince "$disconnected_since" \ - --arg idle "$idle" \ - 'def num: if . == "" then null else (tonumber? // null) end; - {username: $username, state: $state, sessionStart: ($started | num), - disconnectedSince: ($disconnectedSince | num), idleSeconds: ($idle | num)}' -} - -build_heartbeat() { - local sessions_json="${1:-[]}" - local os_id="" - local os_version="" - local os_name="" - local xrdp_version - local xrdp_active="false" - local nfs_mounts - local nfs_reachable - local disk_used - - if [ -r /etc/os-release ]; then - os_id=$(. /etc/os-release && echo "${ID:-}") - os_version=$(. /etc/os-release && echo "${VERSION_ID:-}") - os_name=$(. /etc/os-release && echo "${PRETTY_NAME:-}") - fi - - xrdp_version=$(detect_xrdp_version || true) - if systemctl is-active --quiet xrdp 2>/dev/null; then - xrdp_active="true" - fi - - read -r nfs_mounts nfs_reachable < <(check_nfs) - disk_used=$(df -P / 2>/dev/null | awk 'NR == 2 {gsub("%", "", $5); print $5}') - - /usr/bin/jq -cn \ - --arg agentVersion "$LINUXBROKER_AGENT_VERSION" \ - --argjson scriptVersions "$(collect_script_versions)" \ - --arg settingsVersion "$SETTINGS_VERSION" \ - --arg osId "$os_id" \ - --arg osVersion "$os_version" \ - --arg osName "$os_name" \ - --arg kernel "$(uname -r 2>/dev/null)" \ - --arg desktop "$(detect_desktop)" \ - --arg xrdpVersion "$xrdp_version" \ - --argjson xrdpActive "$xrdp_active" \ - --arg nfsMounts "$nfs_mounts" \ - --arg nfsReachable "$nfs_reachable" \ - --arg load "$(awk '{print $1}' /proc/loadavg 2>/dev/null)" \ - --arg cpuCount "$(nproc 2>/dev/null)" \ - --arg memoryAvailableMb "$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null)" \ - --arg memoryTotalMb "$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null)" \ - --arg diskUsed "$disk_used" \ - --arg uptime "$(awk '{print int($1)}' /proc/uptime 2>/dev/null)" \ - --argjson sessions "$sessions_json" \ - 'def num: if . == "" then null else (tonumber? // null) end; - def text: if . == "" then null else . end; - { - agentVersion: $agentVersion, - scriptVersions: $scriptVersions, - settingsVersion: ($settingsVersion | num), - os: {id: ($osId | text), version: ($osVersion | text), name: ($osName | text)}, - kernel: ($kernel | text), - desktop: $desktop, - xrdp: {version: ($xrdpVersion | text), active: $xrdpActive}, - nfs: { - mounts: ($nfsMounts | num), - reachable: (if $nfsReachable == "true" then true elif $nfsReachable == "false" then false else null end) - }, - loadAverage: ($load | num), - cpuCount: ($cpuCount | num), - memoryAvailableMb: ($memoryAvailableMb | num), - memoryTotalMb: ($memoryTotalMb | num), - rootDiskFreePct: (($diskUsed | num) as $used | if $used == null then null else 100 - $used end), - uptimeSeconds: ($uptime | num), - sessions: $sessions - }' -} - -# Whether this run sends a heartbeat. Watcher runs are extra reconciles triggered by sign-ins -# and sign-outs; the timer run reports on a steady schedule. A broker that answered 404 is -# asked again only once the backoff has passed. -heartbeat_due() { - local backoff_file="$STATE_DIRECTORY/heartbeat_unsupported_until" - local backoff_until - - if [ "$RUN_MODE" = "logind-watcher" ]; then - return 1 - fi - - if [ -s "$backoff_file" ]; then - backoff_until=$(tr -dc '0-9' < "$backoff_file") - if [ -n "$backoff_until" ] && [ "$(date +%s)" -lt "$backoff_until" ]; then - return 1 - fi - fi - - return 0 -} - -send_heartbeat() { - local sessions_json="${1:-[]}" - local api_base_url="YOUR_LINUX_BROKER_API_BASE_URL" - local heartbeat_url="$api_base_url/hosts/$hostname/heartbeat" - local backoff_file="$STATE_DIRECTORY/heartbeat_unsupported_until" - local failed_file="$STATE_DIRECTORY/heartbeat_failed" - local payload_file - local access_token - local http_status - local now - - if ! heartbeat_due; then - return 0 - fi - - now=$(date +%s) - - if ! access_token=$(get_access_token); then - log "Unable to obtain an access token for the heartbeat." - return 0 - fi - - payload_file="$STATE_DIRECTORY/heartbeat.$$.json" - if ! build_heartbeat "$sessions_json" > "$payload_file" 2>/dev/null; then - log "Could not build the heartbeat." - rm -f "$payload_file" - return 0 - fi - - http_status=$(/usr/bin/curl -s -m 10 -w "%{http_code}" -o /dev/null -X POST "$heartbeat_url" \ - -H "Authorization: Bearer $access_token" \ - -H "Content-Type: application/json" \ - --data-binary "@$payload_file") - rm -f "$payload_file" - - if [[ "$http_status" =~ ^2[0-9][0-9]$ ]]; then - rm -f "$backoff_file" - if [ -e "$failed_file" ]; then - rm -f "$failed_file" - log "The broker is accepting heartbeats again." - fi - return 0 - fi - - if [ "$http_status" = "404" ]; then - # An API older than heartbeats, or a host the broker does not know. Neither changes - # within a minute, so ask again later instead of on every run. - printf '%s\n' "$((now + HEARTBEAT_BACKOFF_SECONDS))" > "$backoff_file" - chmod 600 "$backoff_file" - log "The broker does not accept heartbeats from $hostname (HTTP 404). Trying again in $((HEARTBEAT_BACKOFF_SECONDS / 60)) minutes." - return 0 - fi - - # Logged once per outage rather than on every run. - if [ ! -e "$failed_file" ]; then - : > "$failed_file" - chmod 600 "$failed_file" - log "Heartbeat failed (HTTP $http_status). It is retried on every run." - fi -} - -main() { - local session_info_script - local now - local line - local pid - local username - local start_time - local status - local disconnected_at - local prev_user - local active_pid - local start_clock - local sessions_json='[]' - local current_users=() - local previous_users=() - declare -A user_status=() - declare -A user_active_pids=() - declare -A user_start_times=() - - ensure_state_files - ensure_jq_installed - load_settings - refresh_settings - - if ! session_info_script=$(resolve_xrdp_users_info_script); then - log "ERROR: Failed to find an XRDP session inspection script." - exit 1 - fi - - if ! "$session_info_script" > "$CURRENT_USERS_DETAILS"; then - log "ERROR: Failed to execute $session_info_script" - exit 1 - fi - - log "Contents of $CURRENT_USERS_DETAILS:" - cat "$CURRENT_USERS_DETAILS" | tee -a "$LOG_FILE" - - mapfile -t previous_users < "$PREVIOUS_USERS_FILE" - now=$(date +%s) - - while IFS= read -r line; do - [ -z "$line" ] && continue - - pid=$(echo "$line" | awk '{print $1}') - username=$(echo "$line" | awk '{print $2}') - start_time=$(echo "$line" | awk '{print $3}') - start_clock=$(echo "$line" | awk '{print $4}') - status=$(echo "$line" | awk '{print $NF}' | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g' | xargs) - - if [ -z "$username" ] || [ "$pid" = "PID" ]; then - continue - fi - - if ! array_contains "$username" "${current_users[@]}"; then - current_users+=("$username") - fi - - if [ -z "${user_start_times[$username]:-}" ] && [[ "$start_time" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - user_start_times["$username"]="$start_time $start_clock" - fi - - if ! [[ -z "$start_time" || "$start_time" == *"START_TIME"* ]]; then - log "PID: $pid, Username: $username, Start Time: $start_time, Status: $status" - fi - - if [[ "$status" == *"active"* ]]; then - user_status["$username"]="active" - user_active_pids["$username"]+="$pid " - elif [[ "$status" == *"disconnected"* ]]; then - if [ "${user_status[$username]:-}" != "active" ]; then - user_status["$username"]="disconnected" - fi - else - log "User $username reported unexpected session status '$status'." - fi - done < "$CURRENT_USERS_DETAILS" - - for username in "${current_users[@]}"; do - disconnected_at=$(get_disconnect_timestamp "$username") - - if [ "${user_status[$username]:-}" = "active" ]; then - if [ -n "$disconnected_at" ]; then - log "User $username reconnected. Clearing pending grace period." - clear_disconnect_timestamp "$username" - else - log "User $username is active. No action to perform." - fi - - for active_pid in ${user_active_pids[$username]:-}; do - enforce_idle_session "$username" "$active_pid" - done - elif [ "${user_status[$username]:-}" = "disconnected" ]; then - if [ -z "$disconnected_at" ]; then - log "User $username is disconnected. Releasing VM and starting grace period." - - if release_vm "$username"; then - upsert_disconnect_timestamp "$username" "$now" - else - log "ERROR: Release request failed for user $username. The agent will retry on the next run." - fi - else - reconcile_disconnected_user "$username" "$disconnected_at" "$now" - fi - fi - done - - for prev_user in "${previous_users[@]}"; do - [ -z "$prev_user" ] && continue - - if ! array_contains "$prev_user" "${current_users[@]}"; then - log "User $prev_user has no session record. Releasing VM for user $prev_user." - release_vm "$prev_user" || true - clear_disconnect_timestamp "$prev_user" - tsv_clear "$IDLE_WARNED_USERS_FILE" "$prev_user" - fi - done - - if [ "${#current_users[@]}" -gt 0 ]; then - printf "%s\n" "${current_users[@]}" > "$PREVIOUS_USERS_FILE" - else - : > "$PREVIOUS_USERS_FILE" - fi - - chmod 600 "$PREVIOUS_USERS_FILE" - - # The session list reads each active session's idle time from its X server, so it is - # only built on a run that sends it. - if heartbeat_due; then - for username in "${current_users[@]}"; do - active_pid="${user_active_pids[$username]:-}" - active_pid="${active_pid%% *}" - sessions_json=$(/usr/bin/jq -c --argjson entry "$(session_json "$username" "${user_status[$username]:-unknown}" "${user_start_times[$username]:-}" "$active_pid")" '. + [$entry]' <<< "$sessions_json" 2>/dev/null || echo "$sessions_json") - done - - send_heartbeat "$sessions_json" || true - fi - - log "Script completed." -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - acquire_reconcile_lock - log "Script started." - trap "release_reconcile_lock; log 'Script exiting.'" EXIT INT TERM - - main -fi diff --git a/linux_host/session_release_buffer/RHEL/release-session.sh b/linux_host/session_release_buffer/release-session.sh similarity index 97% rename from linux_host/session_release_buffer/RHEL/release-session.sh rename to linux_host/session_release_buffer/release-session.sh index 12a7361..85618e5 100644 --- a/linux_host/session_release_buffer/RHEL/release-session.sh +++ b/linux_host/session_release_buffer/release-session.sh @@ -1,6 +1,7 @@ #!/bin/bash -# Support for RHEL systems +# The Linux Broker session release agent. The same script runs on every supported +# distribution, RHEL-like and Ubuntu. export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -83,6 +84,33 @@ release_reconcile_lock() { fi } +# The bootstrap and the host migration install jq. This puts it back on a host where it was +# removed, since nothing else in a run works without it. +ensure_jq_installed() { + if command -v jq >/dev/null 2>&1; then + return 0 + fi + + log "jq not found. Installing jq..." + + if command -v apt-get >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 install -y jq >/dev/null 2>&1 \ + || { apt-get -o DPkg::Lock::Timeout=120 update >/dev/null 2>&1 \ + && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=120 install -y jq >/dev/null 2>&1; } + elif command -v dnf >/dev/null 2>&1; then + dnf install -y jq >/dev/null 2>&1 + elif command -v yum >/dev/null 2>&1; then + yum install -y jq >/dev/null 2>&1 + fi + + if ! command -v jq >/dev/null 2>&1; then + log "ERROR: Failed to install jq." + exit 1 + fi + + log "jq installed successfully." +} + resolve_xrdp_users_info_script() { if [ -x "$XORG_USERS_INFO_SCRIPT" ]; then echo "$XORG_USERS_INFO_SCRIPT" @@ -1008,6 +1036,7 @@ main() { declare -A user_start_times=() ensure_state_files + ensure_jq_installed load_settings refresh_settings diff --git a/linux_host/session_release_buffer/xrdp-who-xnc.sh b/linux_host/session_release_buffer/xrdp-who-xnc.sh deleted file mode 100644 index c05d27a..0000000 --- a/linux_host/session_release_buffer/xrdp-who-xnc.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# Forked from Evanlinde github repository -# https://github.com/evanlinde/xrdp-who -# Continues use of the MIT license - -# -# Print info about xrdp Xvnc sessions -# - -# The Linux Broker host agent version. Every script in linux_host/ declares the same value -# and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" - -# Setting up color variables for output formatting using tput for portability and readability -if [ -t 1 ] && [ -n "$TERM" ]; then - RED=$(tput setaf 1; tput bold) # Set text color to bold red - GREEN=$(tput setaf 2; tput bold) # Set text color to bold green - YELLOW=$(tput setaf 3; tput bold) # Set text color to bold yellow - ENDCOLOR=$(tput sgr0) # Reset text formatting to default - BLINK=$(tput blink) # Unused in this script, would make text blink - REVERSE=$(tput smso) # Unused in this script, would reverse the background and foreground colors - UNDERLINE=$(tput smul) # Unused in this script, would underline text -else - RED="" - GREEN="" - YELLOW="" - ENDCOLOR="" - BLINK="" - REVERSE="" - UNDERLINE="" -fi - -# Format string for printf to maintain consistent column widths and alignments in the output -_printf="%7s %-20s %-19s %-10s %4s %-12s\n" - -# Print header with specified column names, using the previously defined format -printf "\n${_printf}" PID USERNAME START_TIME GEOMETRY BITS STATUS - -# Get a list of all Xvnc processes, parse their details, and process each line -ps h -C Xvnc -o user:20,pid,lstart,cmd | while read username pid dt1 dt2 dt3 dt4 dt5 xvnc_cmd; do - # Combine date and time parts into a single string - timestring="${dt1} ${dt2} ${dt3} ${dt4} ${dt5}"; - # Convert the start time of the session into a Unix timestamp for comparison - start_time_s=$(date -d "${timestring}" +"%s"); - # Format the start time as YYYY-MM-DD HH:MM - printf -v start_time "%(%Y-%m-%d %H:%M)T" ${start_time_s} - # Highlight the start time in yellow if the session started more than 30 days ago - [ ${start_time_s} -lt $(date -d "-30 days" +%s) ] && start_time="${YELLOW}${start_time}${ENDCOLOR}" - # Parse the Xvnc command for geometry (resolution) and color depth (bits) - read geometry colorbits <<< $(echo ${xvnc_cmd} | awk '{for(i=i;i<=NF;i++){if($i=="-geometry"){geom=$(++i)} if($i=="-depth"){bits=$(++i)}} print geom,bits}'); - # Check if the session is active by looking for its PID in the socket state (ss) command output - sudo ss -tep 2>/dev/null | grep -q pid\=${pid}, && status="${GREEN}active${ENDCOLOR}" || status="${RED}disconnected${ENDCOLOR}"; - # Print the session details using the format string defined earlier - printf "${_printf}" ${pid} ${username} "${start_time}" ${geometry} ${colorbits} "${status}"; -done -# Print an extra newline for clean output separation -echo "" diff --git a/linux_host/tests/test_heartbeat.sh b/linux_host/tests/test_heartbeat.sh index 62d9862..14130c1 100644 --- a/linux_host/tests/test_heartbeat.sh +++ b/linux_host/tests/test_heartbeat.sh @@ -149,5 +149,4 @@ heartbeat_for_script() { assert_file_contains "$LOG_FILE" "accepting heartbeats again" } -heartbeat_for_script "$ROOT_DIR/linux_host/session_release_buffer/Ubuntu/release-session.sh" ubuntu -heartbeat_for_script "$ROOT_DIR/linux_host/session_release_buffer/RHEL/release-session.sh" rhel +heartbeat_for_script "$ROOT_DIR/linux_host/session_release_buffer/release-session.sh" agent diff --git a/linux_host/tests/test_release_session.sh b/linux_host/tests/test_release_session.sh index cc37050..5ee9221 100644 --- a/linux_host/tests/test_release_session.sh +++ b/linux_host/tests/test_release_session.sh @@ -147,7 +147,44 @@ INFO assert_file_exists "$WORK_DIR/agg-heartbeat-$label" } -run_for_script "$ROOT_DIR/linux_host/session_release_buffer/Ubuntu/release-session.sh" ubuntu -run_for_script "$ROOT_DIR/linux_host/session_release_buffer/RHEL/release-session.sh" rhel -aggregation_for_script "$ROOT_DIR/linux_host/session_release_buffer/Ubuntu/release-session.sh" ubuntu -aggregation_for_script "$ROOT_DIR/linux_host/session_release_buffer/RHEL/release-session.sh" rhel \ No newline at end of file +run_for_script "$ROOT_DIR/linux_host/session_release_buffer/release-session.sh" agent +aggregation_for_script "$ROOT_DIR/linux_host/session_release_buffer/release-session.sh" agent + +# The agent runs on every distribution. jq is put back when it is missing, with the +# distribution's package manager. +jq_install_for() { + local manager="$1" + + reset_work + install_basic_shims + # shellcheck source=/dev/null + . "$ROOT_DIR/linux_host/session_release_buffer/release-session.sh" + LOG_FILE="$WORK_DIR/jq-$manager.log" + export FAKE_JQ_CALLS="$WORK_DIR/jq-calls-$manager" + : > "$FAKE_JQ_CALLS" + + command() { + if [ "$1" = "-v" ]; then + case "$2" in + jq) [ -e "$WORK_DIR/jq-installed" ] ;; + "$manager") return 0 ;; + apt-get|dnf|yum) return 1 ;; + *) builtin command "$@" ;; + esac + return + fi + builtin command "$@" + } + apt-get() { echo "apt-get $*" >> "$FAKE_JQ_CALLS"; [[ " $* " == *" install "* ]] && : > "$WORK_DIR/jq-installed"; return 0; } + dnf() { echo "dnf $*" >> "$FAKE_JQ_CALLS"; : > "$WORK_DIR/jq-installed"; return 0; } + yum() { echo "yum $*" >> "$FAKE_JQ_CALLS"; : > "$WORK_DIR/jq-installed"; return 0; } + + ensure_jq_installed + assert_file_contains "$FAKE_JQ_CALLS" "$manager" + assert_file_contains "$FAKE_JQ_CALLS" "install -y jq" + unset -f command apt-get dnf yum +} + +jq_install_for apt-get +jq_install_for dnf +jq_install_for yum \ No newline at end of file From 37e11d2c54fa156e66dcb701771b88451fc13cd4 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 16:48:38 -0400 Subject: [PATCH 05/19] Give Ubuntu hosts the Ubuntu desktop through an xrdp session launcher Ubuntu 24.04 hosts deployed Canonical's server image with no desktop, and the bootstrap's package install failed because Microsoft's repository has no azure-cli package for 24.04, which also left out nfs-common, jq and dconf-cli. The bootstrap now installs ubuntu-desktop-minimal without the first-login wizard and whoopsie, Firefox on its own so an unreachable Snap Store cannot fail the host, and never stops for a prompt. It disables apport, hides the update notifications from broker users, lets xrdp read its TLS key, and no longer adds Microsoft's repository or the Azure CLI. xrdp's own Debian script cannot start Ubuntu's session on Xorg, so every host now starts sessions through linux_host/xrdp-startwm.sh. Its --install points DefaultWindowManager at it, records the previous value, keeps a one-time backup of sesman.ini, installs a polkit rule for the color-manager and PackageKit refresh prompts, and makes xrdp-sesman reload. The launcher starts the desktop named in /etc/linuxbroker/desktop.conf, and without that file runs the distribution's script as before. The RHEL bootstraps and the host migration install it too. patch-host.sh installs the launcher again after every run, keeping its output out of the failure summary, and security updates on Ubuntu keep locally changed configuration files as all updates already did. create-user.sh gives users /bin/bash instead of Ubuntu's /bin/sh. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- .../Configure-RHEL8-Host.sh | 24 + .../Configure-RHEL9-Host.sh | 24 + .../Configure-Ubuntu24_desktop-Host.sh | 316 +++++++----- deploy/DEPLOYMENT.md | 27 +- deploy/Migrate-LinuxHostReleaseAgent.ps1 | 13 +- deploy/bicep/main.bicep | 2 +- deploy/bicep/main.json | 12 +- deploy/bicep/main.resources.bicep | 2 +- deploy/bicep/modules/Linux/main.bicep | 2 +- linux_host/create-user.sh | 15 +- linux_host/patch-host.sh | 42 +- linux_host/tests/test_create_user.sh | 24 + linux_host/tests/test_patch_host.sh | 67 ++- linux_host/tests/test_xrdp_startwm.sh | 454 ++++++++++++++++++ linux_host/xrdp-startwm.sh | 405 ++++++++++++++++ 16 files changed, 1279 insertions(+), 155 deletions(-) create mode 100644 linux_host/tests/test_xrdp_startwm.sh create mode 100644 linux_host/xrdp-startwm.sh diff --git a/README.md b/README.md index f8f8586..33cc332 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,12 @@ The custom script extension for the AVD host: The custom script extensions support the following Linux distributions: - **Red Hat Enterprise Linux (RHEL) 8 and 9** -- **Ubuntu 24 Desktop** +- **Ubuntu 24.04**: Canonical's server image with the Ubuntu desktop added, which xrdp sessions run as Ubuntu on Xorg These scripts: - **Install XRDP and xpra**: Set up XRDP for full desktop access (RDP) and xpra for application virtualization, enabling users to connect via AVD. +- **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the host's GNOME desktop, as Ubuntu on Xorg on Ubuntu. - **Configure Authentication**: Sets up authentication mechanisms for secure user access. - **Deploy the Linux Session Release Agent**: Installs the timer-based reconciliation service plus a `systemd-logind` watcher that can trigger early reconciliations. The timer remains the fallback path so the system still converges even if event delivery is delayed or unavailable. - **Install the Host Settings Agent**: Installs `apply-host-settings.sh` and seeds the settings profile, so screen lock policy and session timings are applied consistently on every supported distribution rather than only on RHEL 8. `LINUXBROKER_DISABLE_SCREEN_LOCK` still chooses the screen lock posture that is seeded; from then on the values are managed from the portal. @@ -341,7 +342,7 @@ The rest of the admin console (sessions, broadcast messages, scaling schedules, ## Roadmap -Planned work beyond this release, including Ubuntu desktop and RHEL 10 support, starting a host on demand, golden images and multi-session hosts, is described in [docs/ROADMAP.md](docs/ROADMAP.md). +Planned work beyond this release, including RHEL 10 support, starting a host on demand, golden images and multi-session hosts, is described in [docs/ROADMAP.md](docs/ROADMAP.md). ## Contributing diff --git a/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index 6961053..d36739a 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -61,6 +61,8 @@ session_control_script_url="$script_source_root/linux_host/session-control.sh" session_control_script="/usr/local/bin/session-control.sh" patch_host_script_url="$script_source_root/linux_host/patch-host.sh" patch_host_script="/usr/local/bin/patch-host.sh" +xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" +xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" xrdp_ini="/etc/xrdp/xrdp.ini" arch=$( /bin/arch ) @@ -86,6 +88,7 @@ activationKey="${RHEL_ACTIVATION_KEY:-}" output_directory="/usr/local/bin" state_directory="/var/lib/linuxbroker-release-session" +desktop_file="/etc/linuxbroker/desktop.conf" SCRIPT_PATH="$output_directory/release-session.sh" WATCHER_SCRIPT_PATH="$output_directory/logind-session-watcher.sh" @@ -323,6 +326,27 @@ sudo chmod +x "$apply_settings_script" sudo chmod +x "$session_control_script" sudo chmod +x "$patch_host_script" +echo "Downloading xrdp-startwm.sh..." +sudo wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" +sudo chmod +x "$xrdp_startwm_script" + +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. +# On RHEL that is the distribution's own session script, as before. +echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." +sudo mkdir -p "$(dirname "$desktop_file")" +sudo chmod 755 "$(dirname "$desktop_file")" +cat <<'EOF' | sudo tee "$desktop_file" >/dev/null +# Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every +# xrdp session. +DESKTOP=gnome +EOF +sudo chmod 644 "$desktop_file" + +if ! sudo "$xrdp_startwm_script" --install; then + echo "ERROR: Could not configure xrdp to start sessions through $xrdp_startwm_script." + exit 1 +fi + # Create AVD user and give limited sudo rights if ! id avdadmin >/dev/null 2>&1; then sudo useradd avdadmin diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index 1ec3234..b7294d1 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -42,6 +42,8 @@ session_control_script_url="$script_source_root/linux_host/session-control.sh" session_control_script="/usr/local/bin/session-control.sh" patch_host_script_url="$script_source_root/linux_host/patch-host.sh" patch_host_script="/usr/local/bin/patch-host.sh" +xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" +xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" arch=$( /bin/arch ) remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" @@ -66,6 +68,7 @@ activationKey="${RHEL_ACTIVATION_KEY:-}" output_directory="/usr/local/bin" state_directory="/var/lib/linuxbroker-release-session" +desktop_file="/etc/linuxbroker/desktop.conf" SCRIPT_PATH="$output_directory/release-session.sh" WATCHER_SCRIPT_PATH="$output_directory/logind-session-watcher.sh" @@ -241,6 +244,9 @@ sudo wget -O "$session_control_script" "$session_control_script_url" echo "Downloading patch-host.sh..." sudo wget -O "$patch_host_script" "$patch_host_script_url" +echo "Downloading xrdp-startwm.sh..." +sudo wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" + echo "Setting execute permissions for downloaded scripts..." sudo chmod +x "$SCRIPT_PATH" sudo chmod +x "$output_directory/xrdp-who-xorg.sh" @@ -250,8 +256,26 @@ sudo chmod +x "$manage_lease_script" sudo chmod +x "$apply_settings_script" sudo chmod +x "$session_control_script" sudo chmod +x "$patch_host_script" +sudo chmod +x "$xrdp_startwm_script" echo "Downloaded scripts are now executable." +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. +# On RHEL that is the distribution's own session script, as before. +echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." +sudo mkdir -p "$(dirname "$desktop_file")" +sudo chmod 755 "$(dirname "$desktop_file")" +cat <<'EOF' | sudo tee "$desktop_file" >/dev/null +# Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every +# xrdp session. +DESKTOP=gnome +EOF +sudo chmod 644 "$desktop_file" + +if ! sudo "$xrdp_startwm_script" --install; then + echo "ERROR: Could not configure xrdp to start sessions through $xrdp_startwm_script." + exit 1 +fi + echo "Creating log and user details files..." sudo mkdir -p "$state_directory" sudo touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index 3d62801..a1c5ddf 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -1,6 +1,8 @@ #!/bin/bash -# Installs and configures the necessary packages for Linux Broker for AVD Access on Ubuntu 24 desktop +# Installs and configures the necessary packages for Linux Broker for AVD Access on Ubuntu +# 24.04: the Ubuntu desktop, which xrdp sessions run as "Ubuntu on Xorg", and the Linux +# Broker host agent. The Custom Script Extension runs it as root. LINUXBROKER_API_BASE_URL="${1:-}" LINUXBROKER_API_CLIENT_ID="${2:-}" @@ -17,6 +19,11 @@ fi LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" +if [ "$(id -u)" -ne 0 ]; then + echo "This script must run as root." + exit 1 +fi + # =============================== # Variables @@ -37,12 +44,28 @@ session_control_script_url="$script_source_root/linux_host/session-control.sh" session_control_script="/usr/local/bin/session-control.sh" patch_host_script_url="$script_source_root/linux_host/patch-host.sh" patch_host_script="/usr/local/bin/patch-host.sh" - -arch=$(uname -m) -remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" +xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" +xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" + +# Disable the GNOME screen saver and screen lock on this host. Enabled by default because a +# locked greeter inside an xrdp session often cannot be unlocked after a reconnect, which +# strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. +disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" +disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') + +case "$disableScreenLock" in + true|1|yes|y) disableScreenLock="true" ;; + false|0|no|n) disableScreenLock="false" ;; + *) + echo "Unsupported LINUXBROKER_DISABLE_SCREEN_LOCK value: $disableScreenLock (expected true or false)" + exit 1 + ;; +esac output_directory="/usr/local/bin" state_directory="/var/lib/linuxbroker-release-session" +desktop_file="/etc/linuxbroker/desktop.conf" +ubuntu_dconf_file="/etc/dconf/db/local.d/10-linuxbroker-ubuntu" SCRIPT_PATH="$output_directory/release-session.sh" WATCHER_SCRIPT_PATH="$output_directory/logind-session-watcher.sh" @@ -60,154 +83,194 @@ WATCHER_SERVICE_PATH="/etc/systemd/system/$WATCHER_SERVICE_NAME" YOUR_LINUXBROKER_API_CLIENT_ID="$LINUXBROKER_API_CLIENT_ID" YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" +# Package installs never stop to ask: dpkg keeps a configuration file that was changed +# locally, needrestart leaves running services alone, and apt waits for the first-boot +# updates that may still hold the package lock. +export DEBIAN_FRONTEND=noninteractive NEEDRESTART_SUSPEND=1 + +apt_get() { + apt-get -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" +} + +# A mirror in the middle of a sync fails the index download now and then. +apt_update() { + local attempt=1 + + until apt_get update; do + if [ "$attempt" -ge 5 ]; then + echo "ERROR: apt-get update failed $attempt times." + return 1 + fi + echo "apt-get update failed. Retrying in 30 seconds (attempt $attempt of 5)..." + attempt=$((attempt + 1)) + sleep 30 + done +} + # =============================== # Execution -echo "Updating and upgrading system packages..." -sudo apt update -y && sudo apt upgrade -y - -# Install necessary dependencies -echo "Installing necessary packages..." -sudo apt install -y wget curl software-properties-common gnupg2 +set -e # Exit immediately if a command exits with a non-zero status -# Add Microsoft packages repository -echo "Adding Microsoft packages repository..." -wget https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb -sudo dpkg -i packages-microsoft-prod.deb -rm packages-microsoft-prod.deb -sudo apt update -y - -# Add Xpra repository -echo "Adding Xpra repository..." -sudo add-apt-repository ppa:xpra/stable -y -sudo apt update -y +echo "Updating and upgrading system packages..." +apt_update +apt_get -y --with-new-pkgs upgrade + +# The first-login wizard would greet every broker user, and crash reports are not collected +# (see apport below), so gnome-initial-setup and whoopsie are left out. Firefox is a snap on +# Ubuntu, and a snap store that cannot be reached would fail the whole install, so it is +# installed on its own afterwards. +desktop_packages=(ubuntu-desktop-minimal gnome-initial-setup- whoopsie-) +if ! dpkg-query -W -f='${Status}' firefox 2>/dev/null | grep -q 'install ok installed'; then + desktop_packages+=(firefox-) +fi -# Install Azure CLI -echo "Installing Azure CLI..." -sudo apt install -y azure-cli nfs-common jq dconf-cli +echo "Installing the Ubuntu desktop, xrdp and the Linux Broker dependencies..." +apt_get -y install jq nfs-common dconf-cli curl wget ufw libnotify-bin x11-utils dbus-user-session \ + xrdp xorgxrdp "${desktop_packages[@]}" # Idle session enforcement degrades gracefully without xprintidle, so a host that cannot # install it must still finish provisioning rather than fail the extension. echo "Installing idle detection support..." -sudo apt install -y xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." +apt_get -y install xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." -# Optional: Install Desktop Environment (Uncomment if needed) -# echo "Installing Desktop Environment..." -# sudo apt install -y xfce4 xfce4-goodies # Lightweight desktop environment - -# Install remote access tools -case "$remoteAccessTool" in - "xrdp") - remoteAccessPackages=("xrdp") - ;; - "xpra") - remoteAccessPackages=("xpra") - ;; - "both") - remoteAccessPackages=("xrdp" "xpra") - ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 - ;; -esac +echo "Installing Firefox..." +if ! apt_get -y install firefox; then + echo "WARNING: Firefox could not be installed; the snap store may be unreachable. Install it later with 'apt-get install firefox'." +fi -echo "Installing remote access packages: ${remoteAccessPackages[*]}" -for pkg in "${remoteAccessPackages[@]}"; do - sudo apt install -y "$pkg" -done +# The xrdp certificate is the snakeoil one, whose key only the ssl-cert group can read. +if getent group ssl-cert >/dev/null && id xrdp >/dev/null 2>&1; then + echo "Letting xrdp read its TLS key..." + usermod -aG ssl-cert xrdp +fi -if [[ "$remoteAccessTool" == "xrdp" || "$remoteAccessTool" == "both" ]]; then - sudo apt install -y xorgxrdp +# Broker users cannot act on crash reports, so apport neither collects them nor asks about them. +echo "Disabling crash reporting..." +if [ -f /etc/default/apport ]; then + sed -i 's/^enabled=1$/enabled=0/' /etc/default/apport fi +systemctl disable --now apport.service >/dev/null 2>&1 || true + +# Broker users cannot install updates or reboot the host, and patching is scheduled from the +# Linux Broker portal, so the update notifications stay quiet. The dconf profile that makes +# the local database take effect is written with the host settings at the end. +echo "Quieting update notifications for broker users..." +mkdir -p "$(dirname "$ubuntu_dconf_file")" +cat > "$ubuntu_dconf_file" <<'EOF' +# Managed by the Linux Broker host bootstrap. +[com/ubuntu/update-notifier] +no-show-notifications=true +show-apport-crashes=false +hide-reboot-notification=true +notify-ubuntu-advantage-available=false +show-livepatch-status-icon=false +EOF +chmod 644 "$ubuntu_dconf_file" +dconf update echo "Setting default target to graphical..." -sudo systemctl set-default graphical.target +systemctl set-default graphical.target echo "Starting graphical target..." -sudo systemctl start graphical.target +systemctl start graphical.target -# Configure Firewall using UFW echo "Configuring firewall..." -sudo apt install -y ufw -sudo ufw allow OpenSSH - -if [[ "$remoteAccessTool" == "xrdp" || "$remoteAccessTool" == "both" ]]; then - sudo ufw allow 3389/tcp - sudo ufw allow 443/tcp -fi - -if [[ "$remoteAccessTool" == "xpra" || "$remoteAccessTool" == "both" ]]; then - sudo ufw allow 443/tcp -fi - -sudo ufw --force enable +ufw allow OpenSSH +ufw allow 3389/tcp +ufw --force enable echo "Firewall configuration completed." -# Download and set up scripts if [ ! -d "$output_directory" ]; then - sudo mkdir -p "$output_directory" + mkdir -p "$output_directory" echo "Directory $output_directory created." fi echo "Downloading release-session.sh..." -sudo wget -O "$SCRIPT_PATH" "$release_session_url" +wget -O "$SCRIPT_PATH" "$release_session_url" -sudo sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" -sudo sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$YOUR_LINUXBROKER_API_CLIENT_ID|g" "$SCRIPT_PATH" +sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" +sed -i "s|YOUR_LINUX_BROKER_API_URL|$YOUR_LINUXBROKER_API_BASE_URL|g" "$SCRIPT_PATH" echo "Downloading xrdp-who-xorg.sh..." -sudo wget -O "$output_directory/xrdp-who-xorg.sh" "$xrdp_who_xorg_url" +wget -O "$output_directory/xrdp-who-xorg.sh" "$xrdp_who_xorg_url" echo "Downloading logind-session-watcher.sh..." -sudo wget -O "$WATCHER_SCRIPT_PATH" "$logind_watcher_url" +wget -O "$WATCHER_SCRIPT_PATH" "$logind_watcher_url" echo "Downloading create-user.sh..." -sudo wget -O "$create_user_script" "$create_user_script_url" +wget -O "$create_user_script" "$create_user_script_url" echo "Downloading manage-lease.sh..." -sudo wget -O "$manage_lease_script" "$manage_lease_script_url" +wget -O "$manage_lease_script" "$manage_lease_script_url" echo "Downloading apply-host-settings.sh..." -sudo wget -O "$apply_settings_script" "$apply_settings_script_url" +wget -O "$apply_settings_script" "$apply_settings_script_url" echo "Downloading session-control.sh..." -sudo wget -O "$session_control_script" "$session_control_script_url" +wget -O "$session_control_script" "$session_control_script_url" echo "Downloading patch-host.sh..." -sudo wget -O "$patch_host_script" "$patch_host_script_url" - -sudo chmod +x "$SCRIPT_PATH" -sudo chmod +x "$output_directory/xrdp-who-xorg.sh" -sudo chmod +x "$WATCHER_SCRIPT_PATH" -sudo chmod +x "$create_user_script" -sudo chmod +x "$manage_lease_script" -sudo chmod +x "$apply_settings_script" -sudo chmod +x "$session_control_script" -sudo chmod +x "$patch_host_script" +wget -O "$patch_host_script" "$patch_host_script_url" + +echo "Downloading xrdp-startwm.sh..." +wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" + +echo "Setting execute permissions for downloaded scripts..." +chmod +x "$SCRIPT_PATH" +chmod +x "$output_directory/xrdp-who-xorg.sh" +chmod +x "$WATCHER_SCRIPT_PATH" +chmod +x "$create_user_script" +chmod +x "$manage_lease_script" +chmod +x "$apply_settings_script" +chmod +x "$session_control_script" +chmod +x "$patch_host_script" +chmod +x "$xrdp_startwm_script" echo "Downloaded scripts are now executable." -sudo mkdir -p "$state_directory" -sudo touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" -sudo chown root:root "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" -sudo chmod 600 "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. +echo "Configuring xrdp to start the Ubuntu desktop..." +mkdir -p "$(dirname "$desktop_file")" +chmod 755 "$(dirname "$desktop_file")" +cat > "$desktop_file" <<'EOF' +# Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every +# xrdp session. gnome is Ubuntu on Xorg. +DESKTOP=gnome +EOF +chmod 644 "$desktop_file" + +if ! "$xrdp_startwm_script" --install; then + echo "ERROR: Could not configure xrdp to start sessions through $xrdp_startwm_script." + exit 1 +fi + +# A restart, rather than the reload --install asks for, so xrdp also joins ssl-cert. +echo "Enabling and restarting xrdp..." +systemctl enable xrdp +systemctl restart xrdp + +echo "Creating log and user details files..." +mkdir -p "$state_directory" +touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" +chown root:root "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" +chmod 600 "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" echo "Removing legacy cron entry for release-session.sh..." tmp_cron=$(mktemp) -sudo crontab -l 2>/dev/null | grep -v -F "$SCRIPT_PATH" > "$tmp_cron" || true +crontab -l 2>/dev/null | grep -v -F "$SCRIPT_PATH" > "$tmp_cron" || true if [ -s "$tmp_cron" ]; then - sudo crontab "$tmp_cron" + crontab "$tmp_cron" else - sudo crontab -r 2>/dev/null || true + crontab -r 2>/dev/null || true fi rm -f "$tmp_cron" echo "Stopping any legacy release-session.sh processes..." -sudo pkill -f "$SCRIPT_PATH" || true +pkill -f "$SCRIPT_PATH" || true echo "Installing systemd service for release-session.sh..." -cat </dev/null +cat > "$SYSTEMD_SERVICE_PATH" </dev/null +cat > "$SYSTEMD_TIMER_PATH" </dev/null +cat > "$WATCHER_SERVICE_PATH" </dev/null 2>&1 || true -sudo systemctl disable --now "$SYSTEMD_TIMER_NAME" >/dev/null 2>&1 || true -sudo systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl daemon-reload -sudo systemctl reset-failed "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl reset-failed "$WATCHER_SERVICE_NAME" >/dev/null 2>&1 || true -sudo systemctl enable --now "$SYSTEMD_TIMER_NAME" -sudo systemctl enable --now "$WATCHER_SERVICE_NAME" -sudo systemctl start "$SYSTEMD_SERVICE_NAME" +systemctl disable --now "$WATCHER_SERVICE_NAME" >/dev/null 2>&1 || true +systemctl disable --now "$SYSTEMD_TIMER_NAME" >/dev/null 2>&1 || true +systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true +systemctl daemon-reload +systemctl reset-failed "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true +systemctl reset-failed "$WATCHER_SERVICE_NAME" >/dev/null 2>&1 || true +systemctl enable --now "$SYSTEMD_TIMER_NAME" +systemctl enable --now "$WATCHER_SERVICE_NAME" +systemctl start "$SYSTEMD_SERVICE_NAME" echo "Systemd timer and logind watcher configured successfully." if ! id avdadmin >/dev/null 2>&1; then - sudo useradd avdadmin + useradd avdadmin fi # Only the commands the broker API actually invokes with sudo. Privileged file work @@ -286,22 +349,33 @@ fi cmds=(userdel groupadd usermod chpasswd "$create_user_script" "$manage_lease_script" "$apply_settings_script" "$session_control_script" "$patch_host_script") full_paths=$(for cmd in "${cmds[@]}"; do command -v "$cmd"; done | paste -sd ',' -) sudoers_tmp="/etc/sudoers.d/avdadmin.tmp" -echo "avdadmin ALL=(ALL) NOPASSWD: $full_paths" | sudo tee "$sudoers_tmp" >/dev/null -sudo chmod 440 "$sudoers_tmp" -if sudo visudo -c -f "$sudoers_tmp" >/dev/null 2>&1; then - sudo mv "$sudoers_tmp" /etc/sudoers.d/avdadmin +echo "avdadmin ALL=(ALL) NOPASSWD: $full_paths" > "$sudoers_tmp" +chmod 440 "$sudoers_tmp" +if visudo -c -f "$sudoers_tmp" >/dev/null 2>&1; then + mv "$sudoers_tmp" /etc/sudoers.d/avdadmin else - sudo rm -f "$sudoers_tmp" + rm -f "$sudoers_tmp" echo "ERROR: Generated sudoers policy failed validation." exit 1 fi echo "avdadmin user is created and permissioned" -# Screen lock policy is generated by apply-host-settings.sh from the fleet-wide settings -# profile, so every supported distribution now receives it. Seeding the defaults here means -# the host starts converged, and the release agent applies any configured profile on its -# next run. -echo "Applying default Linux Broker host settings..." -sudo "$apply_settings_script" --defaults +# Seed the Linux Broker host settings profile. This writes the dconf screen lock policy, +# the dconf profile that makes it take effect, the release agent's settings file, and the +# systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK still +# chooses the screen lock posture; from here on the values are managed from the portal and +# the release agent converges the host to the configured profile on its next run. +if [ "$disableScreenLock" = "true" ]; then + echo "Seeding host settings with the Gnome Desktop screen saver and screen lock disabled..." + settings_seed='{"ScreenLockEnabled":false,"DisableLockScreen":true}' +else + echo "Seeding host settings with the Gnome Desktop screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." + settings_seed='{"ScreenLockEnabled":true,"DisableLockScreen":false}' +fi + +if ! printf '%s' "$settings_seed" | "$apply_settings_script"; then + echo "ERROR: Failed to apply the initial Linux Broker host settings." + exit 1 +fi echo "System configuration complete." diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 2fae40f..4059010 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -100,8 +100,8 @@ The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.exampl - `avdSessionHostCount`: number of AVD hosts to provision. - `linuxHostVmSize`: Linux host VM size. - `avdVmSize`: AVD host VM size. -- `linuxHostOsVersion`: Linux image SKU. Defaults to `9-LVM` (RHEL 9). The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. -- `linuxHostDisableScreenLock`: `true` or `false`. Disables the GNOME screen saver and screen lock on RHEL hosts. Defaults to `true`. See [Linux Host Screen Lock](#linux-host-screen-lock). +- `linuxHostOsVersion`: Linux image SKU. Defaults to `9-LVM` (RHEL 9). The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. `24_04-lts` deploys Canonical's Ubuntu 24.04 server image and adds the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg. +- `linuxHostDisableScreenLock`: `true` or `false`. Disables the GNOME screen saver and screen lock on the Linux hosts. Defaults to `true`. See [Linux Host Screen Lock](#linux-host-screen-lock). - `azureCloudName`: `AzurePublic`, `AzureUSGovernment`, or `AzureCustom`. See [Choosing The Target Azure Cloud](#choosing-the-target-azure-cloud). - `scriptSourceRoot`: root URL the Linux host and AVD host bootstrap scripts are downloaded from. - `domainName`: DNS suffix the broker appends to Linux host names when it connects over SSH. Leave empty to use the deployment's private DNS zone, `linuxbroker.internal`. If you set it, you are responsible for DNS records that resolve `.` from the API's virtual network. @@ -237,8 +237,9 @@ If you prefer to be prompted locally, leave both values unset and run `azd up` f ## Linux Host Screen Lock -RHEL hosts install the `Server with GUI` group, so they run a GNOME desktop. By default the -bootstrap script disables the GNOME screen saver and screen lock on those hosts. +RHEL hosts install the `Server with GUI` group and Ubuntu hosts install the Ubuntu desktop, so +both run a GNOME desktop. By default the bootstrap script disables the GNOME screen saver and +screen lock on those hosts. This is on by default because a locked GNOME greeter inside an xrdp or xpra session frequently cannot be unlocked after a reconnect. When that happens the user cannot get back into the @@ -276,15 +277,12 @@ azd env set linuxHostDisableScreenLock false ``` The bootstrap then seeds the profile with the lock screen left enabled. You can also set -`LINUXBROKER_DISABLE_SCREEN_LOCK=false` in the environment if you run `Configure-RHEL8-Host.sh` -or `Configure-RHEL9-Host.sh` by hand. +`LINUXBROKER_DISABLE_SCREEN_LOCK=false` in the environment if you run `Configure-RHEL8-Host.sh`, +`Configure-RHEL9-Host.sh` or `Configure-Ubuntu24_desktop-Host.sh` by hand. Because the values are part of the host settings profile, this posture can also be changed after deployment from **Host Settings** in the portal, without redeploying anything. -This setting has no effect on the Ubuntu 24.04 image. That target uses the `server` SKU and does -not install a desktop environment, so there is no GNOME screen lock to disable. - ### Verifying on a host ```bash @@ -368,7 +366,7 @@ Important deployment characteristics: - The API's `NFS_SHARE` setting points at the provisioned Azure Files share unless `nfsShare` is set. The storage account disables public network access and shared key access, and it allows non-HTTPS traffic because NFS does not use HTTPS; the private endpoint is the only path to it. - RHEL hosts use Generation 2 images so they can run with Trusted Launch. - The AVD host pool prefers RemoteApp and sets RDP properties that enable Microsoft Entra single sign-on to the Microsoft Entra joined session hosts. -- RHEL hosts have the GNOME screen saver and screen lock disabled unless `linuxHostDisableScreenLock` is `false`. See [Linux Host Screen Lock](#linux-host-screen-lock). +- Linux hosts have the GNOME screen saver and screen lock disabled unless `linuxHostDisableScreenLock` is `false`. See [Linux Host Screen Lock](#linux-host-screen-lock). - Key Vault stores `db-password` and `linux-host`. - The API app receives Key Vault Secrets User access so it can read those secrets at runtime. @@ -561,6 +559,15 @@ This release changes which Linux distributions and desktops the deployment offer - **RHEL 9 is the default Linux host.** New azd environments, and templates deployed without a value, now use `linuxHostOsVersion=9-LVM` instead of `24_04-lts`, which deployed an Ubuntu server with no desktop. An existing environment keeps the value it stored; check it with `azd env get-value linuxHostOsVersion`. - **RHEL 7 is no longer offered.** `7-LVM` is removed from `linuxHostOsVersion`, along with `Configure-RHEL7-Host.sh`; RHEL 7 left maintenance on June 30, 2024. An azd environment that still stores `linuxHostOsVersion=7-LVM` fails template validation at the next `azd provision`, even with `deployLinuxHosts=false`, so set it to a supported value first. A VM's image cannot be changed in place, so for existing RHEL 7 hosts either also set `deployLinuxHosts=false`, which leaves them as they are, or replace them: drain them, delete the VMs in Azure and their records in the portal, and run `azd provision`. Existing RHEL 7 hosts keep working with the broker, and `patch-host.sh` and the host migration still support them. - **One release agent for every distribution.** The separate RHEL and Ubuntu copies of `release-session.sh` are merged into `linux_host/session_release_buffer/release-session.sh`, and the unused `xrdp-who-xnc.sh` is deleted. Ubuntu hosts now also unmount orphaned NFS homes, as RHEL hosts did. Run [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release: a copy from an earlier release downloads the old paths, which no longer exist, and stops before it changes anything. +- **xrdp starts sessions through `xrdp-startwm.sh`.** The bootstrap and the host migration install `/usr/local/bin/xrdp-startwm.sh` and make it the `DefaultWindowManager` in `/etc/xrdp/sesman.ini`. The first change keeps the original file as `sesman.ini.linuxbroker-orig`, the previous value is recorded in `/etc/linuxbroker/xrdp-startwm.conf`, and xrdp-sesman reloads its configuration without ending any session. The launcher starts the desktop named in `/etc/linuxbroker/desktop.conf`, which the bootstrap writes; without that file, as on a migrated host, it runs the distribution's own session script as before. It also adds `/etc/polkit-1/rules.d/45-linuxbroker-xrdp.rules`, so members of `tsusers` are not asked for an administrator's password when their session creates a color profile or refreshes the package lists. Every maintenance patch run installs it again in case an update replaced `sesman.ini`, and security updates on Ubuntu now keep configuration files that were changed locally, as all updates already did. +- **Ubuntu hosts run the Ubuntu desktop.** `24_04-lts` still deploys Canonical's Ubuntu 24.04 server image, and the bootstrap now adds `ubuntu-desktop-minimal`, which xrdp sessions run as Ubuntu on Xorg, so the screen lock and host settings apply to Ubuntu hosts too. The first-login wizard, crash reporting and update notifications are left out, because broker users cannot act on them. Firefox, a snap on Ubuntu, is installed on its own, and a host that cannot reach the Snap Store finishes without it. The bootstrap no longer adds Microsoft's package repository or installs the Azure CLI, and broker users get `/bin/bash` rather than Ubuntu's default `/bin/sh`; existing users are switched at their next sign-in. The previous bootstrap's package install failed on Ubuntu 24.04, because Microsoft's repository has no `azure-cli` package for it, so existing Ubuntu hosts lack `nfs-common`, `jq` and `dconf-cli` and cannot mount NFS homes. Replace them as described for RHEL 7 above, or drain each one and run the new bootstrap on it. Restarting xrdp ends the connections to the host, so it must be drained: + + ```powershell + $root = 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main' + $api = azd env get-value apiUrl + $clientId = azd env get-value apiClientId + az vm run-command invoke -g -n --command-id RunShellScript --scripts "curl -fsSL -o /tmp/linuxbroker-bootstrap.sh $root/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh && LINUXBROKER_SCRIPT_SOURCE_ROOT=$root bash /tmp/linuxbroker-bootstrap.sh $api $clientId" + ``` ## Manual Steps After `azd up` diff --git a/deploy/Migrate-LinuxHostReleaseAgent.ps1 b/deploy/Migrate-LinuxHostReleaseAgent.ps1 index e1b4344..3ef7286 100644 --- a/deploy/Migrate-LinuxHostReleaseAgent.ps1 +++ b/deploy/Migrate-LinuxHostReleaseAgent.ps1 @@ -215,6 +215,7 @@ manage_lease_script="$output_directory/manage-lease.sh" apply_settings_script="$output_directory/apply-host-settings.sh" session_control_script="$output_directory/session-control.sh" patch_host_script="$output_directory/patch-host.sh" +xrdp_startwm_script="$output_directory/xrdp-startwm.sh" release_service_name='linuxbroker-release-session.service' release_timer_name='linuxbroker-release-session.timer' watcher_service_name='linuxbroker-release-session-watcher.service' @@ -299,6 +300,7 @@ manage_lease_script_url="$script_source_root/linux_host/manage-lease.sh" apply_settings_script_url="$script_source_root/linux_host/apply-host-settings.sh" session_control_script_url="$script_source_root/linux_host/session-control.sh" patch_host_script_url="$script_source_root/linux_host/patch-host.sh" +xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" mkdir -p "$output_directory" "$state_directory" "$state_directory/leases" @@ -310,8 +312,9 @@ download_file "$manage_lease_script_url" "$manage_lease_script" download_file "$apply_settings_script_url" "$apply_settings_script" download_file "$session_control_script_url" "$session_control_script" download_file "$patch_host_script_url" "$patch_host_script" +download_file "$xrdp_startwm_script_url" "$xrdp_startwm_script" -chmod +x "$release_script" "$xorg_script" "$watcher_script" "$create_user_script" "$manage_lease_script" "$apply_settings_script" "$session_control_script" "$patch_host_script" +chmod +x "$release_script" "$xorg_script" "$watcher_script" "$create_user_script" "$manage_lease_script" "$apply_settings_script" "$session_control_script" "$patch_host_script" "$xrdp_startwm_script" sed -i "s|YOUR_LINUX_BROKER_API_CLIENT_ID|$api_client_id|g" "$release_script" sed -i "s|YOUR_LINUX_BROKER_API_BASE_URL|$api_base_url|g" "$release_script" @@ -434,6 +437,14 @@ if [ -x "$apply_settings_script" ]; then fi fi +# xrdp starts every session through xrdp-startwm.sh. Until the host bootstrap names a desktop +# in /etc/linuxbroker/desktop.conf, it runs the distribution's session script as before. +launcher_status=0 +"$xrdp_startwm_script" --install || launcher_status=$? +if [ "$launcher_status" -ne 0 ] && [ "$launcher_status" -ne 3 ]; then + echo "WARNING: xrdp-startwm.sh --install failed with exit code $launcher_status." +fi + systemctl disable --now "$watcher_service_name" >/dev/null 2>&1 || true systemctl disable --now "$release_timer_name" >/dev/null 2>&1 || true systemctl disable --now "$release_service_name" >/dev/null 2>&1 || true diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 2efbe07..cbc4e94 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -150,7 +150,7 @@ param linuxHostSshPublicKey string = '' @description('Linux host OS image SKU.') param linuxHostOsVersion string = '9-LVM' -@description('Disable the GNOME screen saver and screen lock on RHEL hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control. Has no effect on the Ubuntu server image, which has no desktop.') +@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param linuxHostDisableScreenLock bool = true @description('AVD host pool name.') diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 0a4c721..35277bb 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "18396639136736869414" + "templateHash": "5357812922203999908" } }, "parameters": { @@ -326,7 +326,7 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on RHEL hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control. Has no effect on the Ubuntu server image, which has no desktop." + "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." } }, "avdHostPoolName": { @@ -550,7 +550,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "14380665631401972090" + "templateHash": "13893275988389033575" } }, "parameters": { @@ -766,7 +766,7 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on RHEL hosts. Set to false to keep the lock screen." + "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Set to false to keep the lock screen." } }, "avdHostPoolName": { @@ -2839,7 +2839,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "2955371549082891553" + "templateHash": "9444761510623079381" } }, "parameters": { @@ -2913,7 +2913,7 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on RHEL hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control. Has no effect on the Ubuntu server image, which has no desktop." + "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." } } }, diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 06d93c3..32c1251 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -87,7 +87,7 @@ param linuxHostSshPublicKey string = '' ]) param linuxHostOsVersion string = '9-LVM' -@description('Disable the GNOME screen saver and screen lock on RHEL hosts. Set to false to keep the lock screen.') +@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Set to false to keep the lock screen.') param linuxHostDisableScreenLock bool = true param avdHostPoolName string = '' diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index f2b6f5c..f1190f5 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -33,7 +33,7 @@ param OSVersion string @description('Root URL the host bootstrap scripts are downloaded from. Point this at a reachable mirror for sovereign or air-gapped clouds.') param scriptSourceRoot string = 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main' -@description('Disable the GNOME screen saver and screen lock on RHEL hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control. Has no effect on the Ubuntu server image, which has no desktop.') +@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param disableScreenLock bool = true var normalizedScriptSourceRoot = endsWith(scriptSourceRoot, '/') ? take(scriptSourceRoot, length(scriptSourceRoot) - 1) : scriptSourceRoot diff --git a/linux_host/create-user.sh b/linux_host/create-user.sh index 51cb913..30a1e6d 100644 --- a/linux_host/create-user.sh +++ b/linux_host/create-user.sh @@ -143,16 +143,25 @@ if [ "$PASSWORD_MODE" = "true" ]; then fi fi -# Create local user if it doesn't exist +# Create local user if it doesn't exist. Ubuntu's useradd would give the user /bin/sh, which +# makes a poor shell in a desktop terminal. log "Check or create user: $USERID $USERNAME $LOCAL_USERHOME" if ! id "$USERNAME" &>/dev/null; then if [ "$PASSWORD_MODE" = "true" ]; then - run_checked "Failed to create user $USERNAME." useradd -d "$LOCAL_USERHOME" -u "$USERID" -U "$USERNAME" -M + run_checked "Failed to create user $USERNAME." useradd -d "$LOCAL_USERHOME" -u "$USERID" -U -s /bin/bash "$USERNAME" -M else - useradd -d "$LOCAL_USERHOME" -u "$USERID" -U "$USERNAME" -M + useradd -d "$LOCAL_USERHOME" -u "$USERID" -U -s /bin/bash "$USERNAME" -M fi else log "User $USERNAME already exists. Skipping useradd." + # Users that an earlier version created on Ubuntu have /bin/sh. + if [ "$PASSWORD_MODE" = "true" ] && [ "$(getent passwd "$USERNAME" | cut -d: -f7)" = "/bin/sh" ]; then + if usermod -s /bin/bash "$USERNAME"; then + log "Changed the login shell of $USERNAME from /bin/sh to /bin/bash." + else + log "Could not change the login shell of $USERNAME from /bin/sh to /bin/bash." + fi + fi fi if [ "$PASSWORD_MODE" = "true" ]; then diff --git a/linux_host/patch-host.sh b/linux_host/patch-host.sh index b54f5f8..72d601c 100644 --- a/linux_host/patch-host.sh +++ b/linux_host/patch-host.sh @@ -15,7 +15,9 @@ # so a run only succeeds when the kernel the host boots next has a usable initramfs; when /boot # is too small for another kernel, the run keeps two kernels rather than the default three. # -# The package manager is dnf on RHEL 8 and 9, yum on RHEL 7 and apt on Ubuntu. Output goes to +# The package manager is dnf on RHEL 8 and 9, yum on RHEL 7 and apt on Ubuntu, where both +# modes keep configuration files that were changed locally. After every run, xrdp is pointed +# at xrdp-startwm.sh again in case the run replaced its sesman.ini. Output goes to # /var/log/linuxbroker-patch.log; the state of the last run is kept under # /var/lib/linuxbroker-release-session. @@ -37,6 +39,8 @@ UNIT_PREFIX="linuxbroker-patch" # A run that has not recorded its process yet is still starting for this long. START_GRACE_SECONDS=60 SUMMARY_MAX_CHARS=200 +XRDP_STARTWM_SCRIPT="/usr/local/bin/xrdp-startwm.sh" +LAUNCHER_LOG_TAG="xrdp-startwm.sh:" usage() { echo "Usage: $0 start [TOKEN]" >&2 @@ -188,7 +192,7 @@ failure_summary() { tail -n 50 "$LOG_FILE" 2>/dev/null \ | LC_ALL=C tr -d '\000-\010\013-\037\177' \ | grep -v '^[[:space:]]*$' \ - | grep -v ' - Patch run ' \ + | grep -v -e ' - Patch run ' -e " - $LAUNCHER_LOG_TAG " \ | tail -n 1 \ | cut -c "1-$SUMMARY_MAX_CHARS" } @@ -407,6 +411,36 @@ status() { report_status } +# unattended-upgrade takes dpkg's options only from the apt configuration, and without these +# it holds back a package whose update would ask about a configuration file that was changed +# locally, such as xrdp's sesman.ini. +apt_security_upgrade() { + local config status=0 + + config=$(mktemp) || return 1 + printf 'Dpkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$config" + APT_CONFIG="$config" unattended-upgrade -v || status=$? + rm -f "$config" + return "$status" +} + +# Points xrdp at the launcher again in case the run replaced sesman.ini. Whatever the launcher +# reports is marked, so the failure summary still shows the upgrade's own last line, and it +# never changes the result of the run. +reinstall_launcher() { + local output + local line + local status=0 + + [ -x "$XRDP_STARTWM_SCRIPT" ] || return 0 + output=$("$XRDP_STARTWM_SCRIPT" --install 2>&1) || status=$? + while IFS= read -r line; do + [ -n "$line" ] && log "$LAUNCHER_LOG_TAG $line" + done <<< "$output" + log "$LAUNCHER_LOG_TAG --install exited with $status." + return 0 +} + # The upgrade itself, in the detached unit or session. run() { local mode="$1" @@ -460,7 +494,7 @@ run() { if [ "$code" -eq 0 ]; then if [ "$mode" = "security" ]; then if command -v unattended-upgrade >/dev/null 2>&1; then - unattended-upgrade -v >> "$LOG_FILE" 2>&1 || code=$? + apt_security_upgrade >> "$LOG_FILE" 2>&1 || code=$? else log "unattended-upgrades is not installed, so security updates alone cannot be applied. Use all updates instead." code=3 @@ -482,6 +516,8 @@ run() { [ "$code" -eq 0 ] && code=5 fi + reinstall_launcher + with_lock load_state if [ "$code" -eq 0 ]; then diff --git a/linux_host/tests/test_create_user.sh b/linux_host/tests/test_create_user.sh index 5cc42f8..6ac2166 100644 --- a/linux_host/tests/test_create_user.sh +++ b/linux_host/tests/test_create_user.sh @@ -26,6 +26,7 @@ new_form_success() { assert_contains "$out" "__CREATE_USER_RESULT=ok__" assert_eq "$(id -u "$user")" "$uid" + assert_eq "$(getent passwd "$user" | cut -d: -f7)" "/bin/bash" "login shell" id -nG "$user" | grep -qw tsusers || fail "missing tsusers membership" id -nG "$user" | grep -qw appusers || fail "missing appusers membership" shadow_after=$(getent shadow "$user") @@ -86,10 +87,32 @@ legacy_form_still_works() { cleanup_user "$user" bash "$SCRIPT" nfs.example:/profiles 21006 "$user" "$LEASE" assert_eq "$(id -u "$user")" "21006" + assert_eq "$(getent passwd "$user" | cut -d: -f7)" "/bin/bash" "login shell" assert_eq "$(cat "/var/lib/linuxbroker-release-session/leases/$user.lease")" "$LEASE" cleanup_user "$user" } +# Ubuntu users that an earlier version created have /bin/sh; any other shell is left alone. +existing_users_get_bash_instead_of_sh() { + local user="lbtestcu8" other="lbtestcu9" + setup_case + cleanup_user "$user" + cleanup_user "$other" + useradd -d "/home/$user" -u 21008 -U -s /bin/sh "$user" -M + useradd -d "/home/$other" -u 21009 -U -s /usr/bin/dash "$other" -M + + printf 'pw1\n' | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21008 "$user" "$LEASE" >/dev/null \ + || fail "the existing user was not prepared" + assert_eq "$(getent passwd "$user" | cut -d: -f7)" "/bin/bash" "switched from /bin/sh" + assert_file_contains /var/log/createuser.log "Changed the login shell of $user from /bin/sh to /bin/bash." + + printf 'pw2\n' | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21009 "$other" "$LEASE" >/dev/null \ + || fail "the other user was not prepared" + assert_eq "$(getent passwd "$other" | cut -d: -f7)" "/usr/bin/dash" "a chosen shell" + cleanup_user "$user" + cleanup_user "$other" +} + legacy_fixture_rejects_new_form() { local out user="lbtestcu7" setup_case @@ -105,4 +128,5 @@ new_form_success validation_failures mount_failure legacy_form_still_works +existing_users_get_bash_instead_of_sh legacy_fixture_rejects_new_form \ No newline at end of file diff --git a/linux_host/tests/test_patch_host.sh b/linux_host/tests/test_patch_host.sh index d0f222c..8b1cdfc 100644 --- a/linux_host/tests/test_patch_host.sh +++ b/linux_host/tests/test_patch_host.sh @@ -7,7 +7,7 @@ SCRIPT="$ROOT_DIR/linux_host/patch-host.sh" STATE_DIR="/var/lib/linuxbroker-release-session" STATE_FILE="$STATE_DIR/patch-state" LOG_FILE="/var/log/linuxbroker-patch.log" -MANAGER_SHIMS=(dnf yum apt-get unattended-upgrade needs-restarting systemd-run) +MANAGER_SHIMS=(dnf yum apt-get unattended-upgrade needs-restarting systemd-run xrdp-startwm.sh) BOOT_SHIMS=(grubby dracut lsinitrd update-initramfs uname df) MADE_SYSTEMD_DIR=0 HID_APT_GET=0 @@ -31,15 +31,18 @@ remove_shims() { } trap remove_shims EXIT -# A package manager that records its arguments. It fails when FAKE_PM_FAIL is set, and waits -# while FAKE_PM_HOLD names a file, so a test can see a run in progress. With FAKE_NEW_KERNEL -# it installs that kernel into /boot, and its initramfs unless FAKE_NO_INITRAMFS is set, and -# makes it the default as RHEL does. +# A package manager that records its arguments, and the apt configuration it was given. It +# fails when FAKE_PM_FAIL is set, and waits while FAKE_PM_HOLD names a file, so a test can +# see a run in progress. With FAKE_NEW_KERNEL it installs that kernel into /boot, and its +# initramfs unless FAKE_NO_INITRAMFS is set, and makes it the default as RHEL does. install_manager() { local name="$1" cat > "$SHIM_DIR/$name" <<'SHIM' #!/bin/bash echo "$(basename "$0") $*" >> "${FAKE_CALLS:-/dev/null}" +if [ -n "${APT_CONFIG:-}" ]; then + echo "APT_CONFIG=$APT_CONFIG $(cat "$APT_CONFIG")" >> "${FAKE_CALLS:-/dev/null}" +fi while [ -n "${FAKE_PM_HOLD:-}" ] && [ -e "$FAKE_PM_HOLD" ]; do sleep 0.2; done if [ -n "${FAKE_NEW_KERNEL:-}" ]; then printf 'kernel' > "/boot/vmlinuz-$FAKE_NEW_KERNEL" @@ -135,7 +138,19 @@ setup_case() { export FAKE_CALLS="$WORK_DIR/calls.log" : > "$FAKE_CALLS" unset FAKE_PM_FAIL FAKE_PM_HOLD FAKE_NEW_KERNEL FAKE_NO_INITRAMFS FAKE_BOOT_FREE_MB FAKE_DRACUT_FAIL \ - FAKE_BOOT_STYLE FAKE_GRUBBY_DEFAULT FAKE_RUNNING + FAKE_BOOT_STYLE FAKE_GRUBBY_DEFAULT FAKE_RUNNING FAKE_LAUNCHER_STATUS +} + +# The session launcher, which records its arguments, reports as the real one does and exits +# with FAKE_LAUNCHER_STATUS. +install_launcher_shim() { + cat > "$SHIM_DIR/xrdp-startwm.sh" <<'SHIM' +#!/bin/bash +echo "xrdp-startwm.sh $*" >> "${FAKE_CALLS:-/dev/null}" +echo "xrdp already starts sessions through /usr/local/bin/xrdp-startwm.sh." +exit "${FAKE_LAUNCHER_STATUS:-0}" +SHIM + chmod +x "$SHIM_DIR/xrdp-startwm.sh" } marker() { @@ -267,6 +282,7 @@ test_apt_on_ubuntu() { install_manager apt-get install_manager unattended-upgrade local out + local config bash "$SCRIPT" start all run5-vm1-1 >/dev/null out=$(wait_for_run) @@ -281,6 +297,13 @@ test_apt_on_ubuntu() { out=$(wait_for_run) assert_file_contains "$FAKE_CALLS" "unattended-upgrade -v" assert_eq "$(marker "$out" REBOOT_REQUIRED)" "yes" + # Only unattended-upgrade is given the configuration that keeps local conffiles, and the + # file is gone afterwards. + assert_eq "$(grep -c '^APT_CONFIG=' "$FAKE_CALLS")" "1" "apt configurations" + assert_file_contains "$FAKE_CALLS" 'Dpkg::Options { "--force-confdef"; "--force-confold"; };' + config=$(sed -n 's/^APT_CONFIG=\([^ ]*\) .*/\1/p' "$FAKE_CALLS") + [ -n "$config" ] || fail "unattended-upgrade was given no apt configuration" + assert_not_exists "$config" # Security updates alone need unattended-upgrades. rm -f "$SHIM_DIR/unattended-upgrade" @@ -471,6 +494,37 @@ test_ubuntu_rebuilds_a_missing_initrd() { assert_eq "$(marker "$out" EXIT_CODE)" "5" } +test_every_run_points_xrdp_at_the_launcher() { + setup_case + install_manager dnf + install_launcher_shim + local out + + bash "$SCRIPT" start security run13-vm1-1 >/dev/null + out=$(wait_for_run) + assert_eq "$(marker "$out" STATE)" "succeeded" + assert_eq "$(tail -n 1 "$FAKE_CALLS")" "xrdp-startwm.sh --install" "after the upgrade" + assert_file_contains "$LOG_FILE" "xrdp-startwm.sh: xrdp already starts sessions through" + assert_file_contains "$LOG_FILE" "xrdp-startwm.sh: --install exited with 0." + + # A launcher that fails does not fail the run. + export FAKE_LAUNCHER_STATUS=1 + bash "$SCRIPT" start security run13-vm1-2 >/dev/null + out=$(wait_for_run) + assert_eq "$(marker "$out" STATE)" "succeeded" + assert_eq "$(marker "$out" EXIT_CODE)" "0" + assert_file_contains "$LOG_FILE" "xrdp-startwm.sh: --install exited with 1." + + # It runs after a failed upgrade too, and the summary is still the upgrade's own error. + export FAKE_PM_FAIL=1 + bash "$SCRIPT" start security run13-vm1-3 >/dev/null + out=$(wait_for_run) + assert_eq "$(marker "$out" STATE)" "failed" + assert_eq "$(marker "$out" EXIT_CODE)" "1" + assert_contains "$(marker "$out" SUMMARY)" "Failed to download metadata" + assert_eq "$(grep -c '^xrdp-startwm.sh --install$' "$FAKE_CALLS")" "3" "launcher runs" +} + test_arguments_are_validated test_status_without_a_run test_dnf_run_detaches_and_succeeds @@ -488,5 +542,6 @@ test_a_full_boot_keeps_two_kernels test_a_missing_or_damaged_initramfs_is_rebuilt test_a_kernel_that_cannot_boot_fails_the_run test_ubuntu_rebuilds_a_missing_initrd +test_every_run_points_xrdp_at_the_launcher echo "patch-host.sh tests passed" diff --git a/linux_host/tests/test_xrdp_startwm.sh b/linux_host/tests/test_xrdp_startwm.sh new file mode 100644 index 0000000..438510a --- /dev/null +++ b/linux_host/tests/test_xrdp_startwm.sh @@ -0,0 +1,454 @@ +#!/bin/bash +set -uo pipefail +# shellcheck source=linux_host/tests/common.sh +. "$(dirname "$0")/common.sh" + +SCRIPT="$ROOT_DIR/linux_host/xrdp-startwm.sh" +LAUNCHER="/usr/local/bin/xrdp-startwm.sh" +SESMAN_INI="/etc/xrdp/sesman.ini" +BACKUP="/etc/xrdp/sesman.ini.linuxbroker-orig" +STATE_FILE="/etc/linuxbroker/xrdp-startwm.conf" +DESKTOP_FILE="/etc/linuxbroker/desktop.conf" +RULE_FILE="/etc/polkit-1/rules.d/45-linuxbroker-xrdp.rules" +FAKE_SESMAN_PID="" + +# Everything the tests create. Whatever was there before is set aside and put back. +TOUCHED=(/etc/xrdp /usr/libexec/xrdp /etc/polkit-1 /etc/X11 /usr/share/gnome-session /etc/linuxbroker + "$LAUNCHER" "$SHIM_DIR/systemctl" "$SHIM_DIR/gnome-session" "$SHIM_DIR/logger") + +stop_fake_sesman() { + if [ -n "$FAKE_SESMAN_PID" ]; then + kill "$FAKE_SESMAN_PID" 2>/dev/null || true + wait "$FAKE_SESMAN_PID" 2>/dev/null || true + FAKE_SESMAN_PID="" + fi + unset FAKE_SESMAN_PID_FILE +} + +save_touched() { + local path + for path in "${TOUCHED[@]}"; do + if [ -e "$path" ] || [ -L "$path" ]; then + rm -rf "$path.lbtest-saved" + mv "$path" "$path.lbtest-saved" + fi + done +} + +restore_touched() { + local path + stop_fake_sesman + for path in "${TOUCHED[@]}"; do + rm -rf "$path" + if [ -e "$path.lbtest-saved" ] || [ -L "$path.lbtest-saved" ]; then + mv "$path.lbtest-saved" "$path" + fi + done +} + +save_touched +trap restore_touched EXIT + +setup_case() { + local path + stop_fake_sesman + for path in "${TOUCHED[@]}"; do + rm -rf "$path" + done + reset_work + export FAKE_CALLS="$WORK_DIR/calls.log" + : > "$FAKE_CALLS" + install -m 755 "$SCRIPT" "$LAUNCHER" + # Reports the fake xrdp-sesman, when one runs, as the service's main process. + cat > "$SHIM_DIR/systemctl" <<'SHIM' +#!/bin/bash +echo "systemctl $*" >> "${FAKE_CALLS:-/dev/null}" +if [ "$1" = "show" ]; then + cat "${FAKE_SESMAN_PID_FILE:-/nonexistent}" 2>/dev/null || echo 0 +fi +exit 0 +SHIM + cat > "$SHIM_DIR/logger" <<'SHIM' +#!/bin/bash +echo "logger $*" >> "${FAKE_CALLS:-/dev/null}" +SHIM + chmod +x "$SHIM_DIR/systemctl" "$SHIM_DIR/logger" + mkdir -p /etc/xrdp +} + +# A process that counts the SIGHUPs it receives, standing in for xrdp-sesman. +start_fake_sesman() { + local attempts=0 + export FAKE_SESMAN_PID_FILE="$WORK_DIR/sesman.pid" + rm -f "$WORK_DIR/hup" "$WORK_DIR/sesman.ready" + # shellcheck disable=SC2016 # expanded by the inner shell + bash -c 'trap "echo hup >> \"\$1\"" HUP; : > "$2"; while :; do sleep 0.1; done' \ + fake-sesman "$WORK_DIR/hup" "$WORK_DIR/sesman.ready" & + FAKE_SESMAN_PID=$! + echo "$FAKE_SESMAN_PID" > "$FAKE_SESMAN_PID_FILE" + while [ ! -e "$WORK_DIR/sesman.ready" ]; do + attempts=$((attempts + 1)) + [ "$attempts" -gt 50 ] && fail "the fake xrdp-sesman did not start" + sleep 0.1 + done +} + +hup_count() { + if [ -f "$WORK_DIR/hup" ]; then + wc -l < "$WORK_DIR/hup" | tr -d ' ' + else + echo 0 + fi +} + +# The reloads received once at least $1 have arrived, or after two seconds. A signal is only +# handled when the fake's sleep ends, so a little more time is allowed for an extra one. +wait_for_hups() { + local attempts=0 + while [ "$(hup_count)" -lt "$1" ] && [ "$attempts" -lt 20 ]; do + attempts=$((attempts + 1)) + sleep 0.1 + done + sleep 0.3 + hup_count +} + +# A stand-in for a session script that records how it was started. +fake_session_script() { + local path="$1" label="$2" + mkdir -p "$(dirname "$path")" + cat > "$path" < "\${LBTEST_SESSION_OUT:-/dev/null}" +SHIM + chmod 755 "$path" +} + +state_value() { + sed -n 's/^ORIGINAL_WM=//p' "$STATE_FILE" +} + +write_ubuntu_sesman() { + cat > "$SESMAN_INI" <<'INI' +;; See `man 5 sesman.ini` for details + +[Globals] +; listening port +ListenPort=3350 +EnableUserWindowManager=true +; Give in relative path to user's home directory +UserWindowManager=startwm.sh +; Give in full path or relative path to /etc/xrdp +DefaultWindowManager=startwm.sh +; Give in full path or relative path to /etc/xrdp +ReconnectScript=reconnectwm.sh + +[Security] +AllowRootLogin=false +DefaultWindowManager=not-read-here.sh +INI +} + +test_install_on_ubuntu() { + setup_case + write_ubuntu_sesman + chmod 640 "$SESMAN_INI" + fake_session_script /etc/xrdp/startwm.sh debian-startwm + mkdir -p /etc/polkit-1/rules.d + start_fake_sesman + local original out status ini_before + + original=$(cat "$SESMAN_INI") + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "install: $out" + assert_contains "$out" "falls back to /etc/xrdp/startwm.sh" + assert_eq "$(grep -c '^DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh$' "$SESMAN_INI")" "1" + assert_file_contains "$SESMAN_INI" "UserWindowManager=startwm.sh" + assert_file_contains "$SESMAN_INI" "ReconnectScript=reconnectwm.sh" + assert_file_contains "$SESMAN_INI" "DefaultWindowManager=not-read-here.sh" + assert_file_contains "$SESMAN_INI" "; Give in relative path to user's home directory" + assert_eq "$(stat -c %a "$SESMAN_INI")" "640" "sesman.ini keeps its mode" + assert_eq "$(cat "$BACKUP")" "$original" "backup" + assert_eq "$(state_value)" "/etc/xrdp/startwm.sh" + assert_eq "$(stat -c %a "$STATE_FILE")" "644" + assert_eq "$(stat -c %a /etc/linuxbroker)" "755" + assert_file_contains "$RULE_FILE" 'subject.isInGroup("tsusers")' + assert_file_contains "$RULE_FILE" '"org.freedesktop.packagekit.system-sources-refresh"' + assert_file_contains "$RULE_FILE" '"org.freedesktop.color-manager.create-device"' + assert_eq "$(stat -c %a "$RULE_FILE")" "644" + assert_eq "$(ls -A /etc/xrdp | tr '\n' ' ')" "sesman.ini sesman.ini.linuxbroker-orig startwm.sh " "no temporary files" + assert_eq "$(ls -A /etc/polkit-1/rules.d | tr '\n' ' ')" "45-linuxbroker-xrdp.rules " + assert_eq "$(wait_for_hups 1)" "1" "xrdp-sesman reloaded" + + # Running it again changes nothing, and reloads nothing. + ini_before=$(md5sum "$SESMAN_INI") + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "second install: $out" + assert_contains "$out" "already starts sessions" + assert_eq "$(md5sum "$SESMAN_INI")" "$ini_before" + assert_eq "$(cat "$BACKUP")" "$original" "backup after a second install" + assert_eq "$(wait_for_hups 1)" "1" "no second reload" + + # The rule is managed. + echo "// edited" >> "$RULE_FILE" + bash "$LAUNCHER" --install >/dev/null 2>&1 || fail "install over an edited rule" + assert_not_contains_file "$RULE_FILE" "// edited" + + # A lost record is rebuilt from the backup, not from the first fallback. + fake_session_script /usr/libexec/xrdp/startwm-bash.sh fallback + rm -f "$STATE_FILE" + bash "$LAUNCHER" --install >/dev/null 2>&1 || fail "install without a record" + assert_eq "$(state_value)" "/etc/xrdp/startwm.sh" "record rebuilt from the backup" + assert_eq "$(md5sum "$SESMAN_INI")" "$ini_before" + + # A package update that replaced sesman.ini is taken over again; the first backup stays. + write_ubuntu_sesman + echo "; new in this version" >> "$SESMAN_INI" + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "install over a replaced sesman.ini: $out" + assert_eq "$(grep -c '^DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh$' "$SESMAN_INI")" "1" + assert_file_contains "$SESMAN_INI" "; new in this version" + assert_eq "$(cat "$BACKUP")" "$original" "the first backup is kept" + assert_eq "$(wait_for_hups 2)" "2" "reloaded again" +} + +test_install_on_rhel() { + setup_case + cat > "$SESMAN_INI" <<'INI' +[Globals] +ListenPort=3350 +DefaultWindowManager=startwm-bash.sh +ReconnectScript=reconnectwm.sh +INI + fake_session_script /usr/libexec/xrdp/startwm-bash.sh rhel-startwm + local out status + + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "install: $out" + assert_eq "$(state_value)" "/usr/libexec/xrdp/startwm-bash.sh" + assert_file_contains "$SESMAN_INI" "DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh" + assert_contains "$out" "polkit is not installed" + assert_not_exists /etc/polkit-1 + # Without a running xrdp-sesman there is nothing to reload. + assert_file_contains "$FAKE_CALLS" "systemctl show --property MainPID --value xrdp-sesman.service" +} + +test_install_reads_sesman_ini_as_xrdp_does() { + local out status + + # A missing key is xrdp's default, startwm.sh, and is added after the section header. + setup_case + printf '[globals]\nListenPort=3350\n\n[Security]\nAllowRootLogin=false\n' > "$SESMAN_INI" + fake_session_script /etc/xrdp/startwm.sh debian-startwm + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "missing key: $out" + assert_eq "$(sed -n '2p' "$SESMAN_INI")" "DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh" "added after the header" + assert_eq "$(grep -c 'DefaultWindowManager' "$SESMAN_INI")" "1" + assert_eq "$(state_value)" "/etc/xrdp/startwm.sh" "xrdp's default" + + # Names are case-insensitive and values trimmed; the last value wins. + setup_case + printf '[GLOBALS]\nDefaultWindowManager=startwm.sh\n defaultwindowmanager = /usr/libexec/xrdp/custom.sh \n' > "$SESMAN_INI" + fake_session_script /usr/libexec/xrdp/custom.sh custom + fake_session_script /etc/xrdp/startwm.sh debian-startwm + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "mixed case: $out" + assert_eq "$(state_value)" "/usr/libexec/xrdp/custom.sh" + assert_eq "$(grep -ci 'defaultwindowmanager' "$SESMAN_INI")" "2" + assert_eq "$(grep -c '^DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh$' "$SESMAN_INI")" "2" + + # A script that is not there falls back to the distribution's. + setup_case + printf '[Globals]\nDefaultWindowManager=/usr/libexec/xrdp/missing.sh\n' > "$SESMAN_INI" + fake_session_script /etc/xrdp/startwm.sh debian-startwm + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "missing script: $out" + assert_eq "$(state_value)" "/etc/xrdp/startwm.sh" +} + +test_install_refusals() { + local out status before + + # No xrdp: exit 3, and nothing is written. + setup_case + rm -rf /etc/xrdp + mkdir -p /etc/polkit-1/rules.d + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "3" "without xrdp: $out" + assert_contains "$out" "xrdp is not installed" + assert_not_exists /etc/linuxbroker + assert_not_exists "$RULE_FILE" + + # No [Globals] section: nothing changes. + setup_case + printf '[Security]\nAllowRootLogin=false\n' > "$SESMAN_INI" + fake_session_script /etc/xrdp/startwm.sh debian-startwm + before=$(cat "$SESMAN_INI") + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "1" "without [Globals]: $out" + assert_contains "$out" "has no [Globals] section" + assert_eq "$(cat "$SESMAN_INI")" "$before" + assert_not_exists "$STATE_FILE" + assert_not_exists "$BACKUP" + assert_eq "$(ls -A /etc/xrdp | tr '\n' ' ')" "sesman.ini startwm.sh " "no temporary files" + + # No session script to fall back to. + setup_case + printf '[Globals]\nDefaultWindowManager=startwm.sh\n' > "$SESMAN_INI" + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "1" "without a session script: $out" + assert_contains "$out" "No xrdp session script was found" + assert_file_contains "$SESMAN_INI" "DefaultWindowManager=startwm.sh" + + # Only root. + setup_case + write_ubuntu_sesman + fake_session_script /etc/xrdp/startwm.sh debian-startwm + out=$(setpriv --reuid=65534 --regid=65534 --clear-groups bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "1" "as nobody: $out" + assert_contains "$out" "must run as root" + assert_file_contains "$SESMAN_INI" "DefaultWindowManager=startwm.sh" + + # Only the one option. + out=$(bash "$LAUNCHER" --install extra 2>&1); status=$? + assert_eq "$status" "2" "extra argument" +} + +# A Debian-family host with GNOME: x11-common's Xsession and xrdp's own script, recorded. +setup_debian_session() { + fake_session_script /etc/X11/Xsession xsession + mkdir -p /etc/X11/Xsession.d /usr/share/gnome-session/sessions /etc/linuxbroker "$WORK_DIR/home" + fake_session_script /etc/xrdp/startwm.sh debian-startwm + printf 'ORIGINAL_WM=/etc/xrdp/startwm.sh\n' > "$STATE_FILE" + printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/gnome-session" + chmod 755 "$SHIM_DIR/gnome-session" + : > /usr/share/gnome-session/sessions/ubuntu.session + printf 'LBTEST_PROFILE=sourced\nexport LBTEST_PROFILE\n' > "$WORK_DIR/home/.profile" +} + +# Starts a session the way xrdp-sesman does: as the user, in their home, with no arguments. +run_session() { + rm -f "$WORK_DIR/session.out" + (cd "$WORK_DIR/home" && env -i PATH="$SHIM_DIR:/usr/bin:/bin" HOME="$WORK_DIR/home" FAKE_CALLS="$FAKE_CALLS" \ + LBTEST_SESSION_OUT="$WORK_DIR/session.out" bash "$LAUNCHER") + [ -f "$WORK_DIR/session.out" ] || fail "no session script ran" +} + +session_value() { + sed -n "s/^$1=//p" "$WORK_DIR/session.out" +} + +test_ubuntu_on_xorg() { + setup_case + setup_debian_session + printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" + + run_session + assert_eq "$(session_value ran)" "xsession" + assert_eq "$(session_value args)" "gnome-session --session=ubuntu" + assert_eq "$(session_value DESKTOP_SESSION)" "ubuntu" + assert_eq "$(session_value XDG_SESSION_DESKTOP)" "ubuntu" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "ubuntu:GNOME" + assert_eq "$(session_value GNOME_SHELL_SESSION_MODE)" "ubuntu" + assert_eq "$(session_value XDG_SESSION_TYPE)" "x11" + assert_eq "$(session_value LBTEST_PROFILE)" "sourced" "the profiles are read first, as xrdp's script does" + assert_file_contains "$FAKE_CALLS" "logger -t linuxbroker-startwm -- Starting gnome" + + # Quotes and case do not matter. + printf '# Written by the bootstrap.\nDESKTOP="GNOME"\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value args)" "gnome-session --session=ubuntu" + + # Without Ubuntu's session, upstream GNOME. + rm -f /usr/share/gnome-session/sessions/ubuntu.session + run_session + assert_eq "$(session_value ran)" "xsession" + assert_eq "$(session_value args)" "gnome-session" + assert_eq "$(session_value DESKTOP_SESSION)" "gnome" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "GNOME" + assert_eq "$(session_value GNOME_SHELL_SESSION_MODE)" "" + assert_eq "$(session_value XDG_SESSION_TYPE)" "x11" +} + +test_otherwise_the_distribution_script_runs() { + setup_case + setup_debian_session + + # Without desktop.conf, exactly what xrdp ran before. + run_session + assert_eq "$(session_value ran)" "debian-startwm" + assert_eq "$(session_value args)" "" + assert_eq "$(session_value DESKTOP_SESSION)" "" + assert_eq "$(session_value XDG_SESSION_TYPE)" "" + + printf 'DESKTOP=kde\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "debian-startwm" "a desktop it does not start" + assert_file_contains "$FAKE_CALLS" "Ignoring DESKTOP=kde" + + # shellcheck disable=SC2016 # the command must reach the file unexpanded + printf 'DESKTOP=$(touch %s/pwned)\n' "$WORK_DIR" > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "debian-startwm" "desktop.conf is never sourced" + assert_not_exists "$WORK_DIR/pwned" + + printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" + rm -f "$SHIM_DIR/gnome-session" + run_session + assert_eq "$(session_value ran)" "debian-startwm" "GNOME is not installed" + assert_file_contains "$FAKE_CALLS" "gnome is not installed" +} + +test_rhel_runs_its_own_script() { + setup_case + fake_session_script /etc/X11/xinit/Xsession rhel-xsession + fake_session_script /usr/libexec/xrdp/startwm-bash.sh rhel-startwm + mkdir -p /etc/linuxbroker "$WORK_DIR/home" + printf 'ORIGINAL_WM=/usr/libexec/xrdp/startwm-bash.sh\n' > "$STATE_FILE" + printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" + printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/gnome-session" + chmod 755 "$SHIM_DIR/gnome-session" + + run_session + assert_eq "$(session_value ran)" "rhel-startwm" +} + +test_an_unusable_record_falls_back() { + local record + setup_case + setup_debian_session + fake_session_script /usr/libexec/xrdp/startwm-bash.sh fallback + # Each would run if only the path were checked: a relative one resolves in the user's home. + fake_session_script "$WORK_DIR/home/startwm.sh" users-own + fake_session_script "/etc/xrdp/start wm.sh" space + fake_session_script "/etc/xrdp/startwm.sh;reboot" semicolon + + for record in /etc/xrdp/missing.sh startwm.sh "$LAUNCHER" "/etc/xrdp/start wm.sh" "/etc/xrdp/startwm.sh;reboot"; do + printf 'ORIGINAL_WM=%s\n' "$record" > "$STATE_FILE" + run_session + assert_eq "$(session_value ran)" "fallback" "record $record" + done + + # With no xrdp script at all, the X session starts directly. + rm -f /usr/libexec/xrdp/startwm-bash.sh /etc/xrdp/startwm.sh + run_session + assert_eq "$(session_value ran)" "xsession" + assert_eq "$(session_value args)" "" +} + +test_install_on_ubuntu +test_install_on_rhel +test_install_reads_sesman_ini_as_xrdp_does +test_install_refusals +test_ubuntu_on_xorg +test_otherwise_the_distribution_script_runs +test_rhel_runs_its_own_script +test_an_unusable_record_falls_back + +echo "xrdp-startwm.sh tests passed" diff --git a/linux_host/xrdp-startwm.sh b/linux_host/xrdp-startwm.sh new file mode 100644 index 0000000..7a09330 --- /dev/null +++ b/linux_host/xrdp-startwm.sh @@ -0,0 +1,405 @@ +#!/bin/bash +# +# Usage: xrdp-startwm.sh --install +# xrdp-startwm.sh +# +# The script xrdp starts every desktop session with. It starts the desktop that +# /etc/linuxbroker/desktop.conf names, which the host bootstrap writes: the distribution's own +# script cannot start Ubuntu's session on Xorg, or choose between desktops installed side by +# side. Anything this script does not handle, including a host without desktop.conf, runs the +# distribution's script exactly as before. +# +# --install, as root, points DefaultWindowManager in /etc/xrdp/sesman.ini at this script. It +# records the script it replaces in /etc/linuxbroker/xrdp-startwm.conf, keeps the original +# file as sesman.ini.linuxbroker-orig, and installs a polkit rule so broker users are not asked +# for an administrator's password inside an xrdp session. It is idempotent: the host migration +# runs it, and patch-host.sh runs it after every patch run in case an update replaced +# sesman.ini. It exits 3 when xrdp is not installed, and 1 on any other failure. + +# The Linux Broker host agent version. Every script in linux_host/ declares the same value +# and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. +LINUXBROKER_AGENT_VERSION="1.1.0" + +LAUNCHER_PATH="/usr/local/bin/xrdp-startwm.sh" +SESMAN_INI="/etc/xrdp/sesman.ini" +SESMAN_BACKUP="/etc/xrdp/sesman.ini.linuxbroker-orig" +SESMAN_SERVICE="xrdp-sesman.service" +SETTINGS_DIRECTORY="/etc/linuxbroker" +STATE_FILE="$SETTINGS_DIRECTORY/xrdp-startwm.conf" +DESKTOP_FILE="$SETTINGS_DIRECTORY/desktop.conf" +POLKIT_RULES_DIRECTORY="/etc/polkit-1/rules.d" +POLKIT_RULE_FILE="$POLKIT_RULES_DIRECTORY/45-linuxbroker-xrdp.rules" +UBUNTU_SESSION_FILE="/usr/share/gnome-session/sessions/ubuntu.session" + +# Where a relative DefaultWindowManager lives: /etc/xrdp upstream, /usr/libexec/xrdp in the +# Fedora and EPEL packages. +WM_DIRECTORIES=(/etc/xrdp /usr/libexec/xrdp) +# The distribution scripts, tried in this order when the recorded one is unusable. +FALLBACK_WMS=(/usr/libexec/xrdp/startwm-bash.sh /usr/libexec/xrdp/startwm.sh /etc/xrdp/startwm.sh) + +SESMAN_TMP="" + +usage() { + echo "Usage: $0 --install" >&2 + exit 2 +} + +fail() { + echo "ERROR: $1" >&2 + exit 1 +} + +restore_context() { + if command -v restorecon >/dev/null 2>&1; then + restorecon "$1" >/dev/null 2>&1 || true + fi +} + +is_launcher() { + local target + + [ "$1" = "$LAUNCHER_PATH" ] && return 0 + case "$1" in + /*) ;; + *) return 1 ;; + esac + target=$(readlink -f -- "$1" 2>/dev/null) || return 1 + [ -n "$target" ] && [ "$target" = "$(readlink -f -- "$LAUNCHER_PATH" 2>/dev/null)" ] +} + +# A session script that can be run: a plain absolute path to an executable file other than +# this script. +usable_wm() { + [[ "$1" =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 + if [ ! -f "$1" ] || [ ! -x "$1" ]; then + return 1 + fi + ! is_launcher "$1" +} + +# Prints the script a DefaultWindowManager value names, when it is usable. +resolve_wm() { + local directory + + case "$1" in + "") + return 1 + ;; + /*) + usable_wm "$1" && printf '%s\n' "$1" + ;; + *) + for directory in "${WM_DIRECTORIES[@]}"; do + if usable_wm "$directory/$1"; then + printf '%s\n' "$directory/$1" + return 0 + fi + done + return 1 + ;; + esac +} + +first_fallback_wm() { + local candidate + + for candidate in "${FALLBACK_WMS[@]}"; do + if usable_wm "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +# The distribution script --install recorded, when it is still usable. The file is read, +# never sourced. +recorded_wm() { + local value + + [ -r "$STATE_FILE" ] || return 1 + value=$(sed -n 's/^ORIGINAL_WM=//p' "$STATE_FILE" 2>/dev/null | tail -n 1) + usable_wm "$value" && printf '%s\n' "$value" +} + +# Prints "=" and the value of DefaultWindowManager in [Globals], or nothing when it is not +# set. As in xrdp, names are case-insensitive, values are trimmed and the last one wins. +read_default_wm() { + awk ' + { sub(/\r$/, "") } + /^[[:space:]]*[;#]/ { next } + /^[[:space:]]*\[/ { + section = $0 + sub(/^[[:space:]]*\[/, "", section) + sub(/\].*$/, "", section) + in_globals = (tolower(section) == "globals") + next + } + in_globals && index($0, "=") > 0 { + key = substr($0, 1, index($0, "=") - 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + if (tolower(key) == "defaultwindowmanager") { + value = substr($0, index($0, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + found = 1 + } + } + END { if (found) print "=" value } + ' "$1" +} + +# Prints sesman.ini with DefaultWindowManager in [Globals] set to this script, adding the key +# after the section header when add_key is 1. Fails when there is no [Globals] section. +rewrite_sesman() { + awk -v launcher="$LAUNCHER_PATH" -v add_key="$2" ' + { + line = $0 + sub(/\r$/, "", line) + } + line ~ /^[[:space:]]*[;#]/ { print; next } + line ~ /^[[:space:]]*\[/ { + section = line + sub(/^[[:space:]]*\[/, "", section) + sub(/\].*$/, "", section) + in_globals = (tolower(section) == "globals") + print + if (in_globals) { + seen = 1 + if (add_key == "1") print "DefaultWindowManager=" launcher + } + next + } + in_globals && index(line, "=") > 0 { + key = substr(line, 1, index(line, "=") - 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + if (tolower(key) == "defaultwindowmanager") { + print "DefaultWindowManager=" launcher + next + } + } + { print } + END { if (!seen) exit 1 } + ' "$1" +} + +# Writes content to a file only when it differs, through a temporary file in the same +# directory so a reader never sees half of it. +write_managed_file() { + local path="$1" content="$2" tmp + + if [ -f "$path" ] && [ "$(cat "$path")" = "$content" ]; then + return 0 + fi + tmp=$(mktemp "$path.XXXXXX") || return 1 + if ! printf '%s\n' "$content" > "$tmp" || ! chmod 644 "$tmp" || ! mv -f "$tmp" "$path"; then + rm -f "$tmp" + return 1 + fi + restore_context "$path" +} + +write_state() { + if ! mkdir -p "$SETTINGS_DIRECTORY" || ! chmod 755 "$SETTINGS_DIRECTORY"; then + return 1 + fi + write_managed_file "$STATE_FILE" "# Managed by xrdp-startwm.sh: the session script xrdp ran before Linux Broker's. +ORIGINAL_WM=$1" +} + +polkit_rule() { + cat <<'RULE' +// Managed by xrdp-startwm.sh (Linux Broker). Manual edits are overwritten. +// +// polkit asks for an administrator's password when a remote session creates a color profile +// for its display or refreshes the package lists, and broker users never have one. Both are +// allowed for them (the tsusers group) instead. +polkit.addRule(function(action, subject) { + var allowed = [ + "org.freedesktop.color-manager.create-device", + "org.freedesktop.color-manager.create-profile", + "org.freedesktop.color-manager.delete-device", + "org.freedesktop.color-manager.delete-profile", + "org.freedesktop.color-manager.modify-device", + "org.freedesktop.color-manager.modify-profile", + "org.freedesktop.packagekit.system-sources-refresh" + ]; + if (allowed.indexOf(action.id) >= 0 && subject.isInGroup("tsusers")) { + return polkit.Result.YES; + } +}); +RULE +} + +install_polkit_rule() { + if [ ! -d "$POLKIT_RULES_DIRECTORY" ]; then + echo "polkit is not installed, so no polkit rule is needed." + return 0 + fi + # polkitd notices the new file by itself. + write_managed_file "$POLKIT_RULE_FILE" "$(polkit_rule)" +} + +# xrdp-sesman reloads sesman.ini on SIGHUP without touching running sessions, which a restart +# can end. xrdp 0.10 also reads it again for every new session. +reload_sesman() { + local pid + + command -v systemctl >/dev/null 2>&1 || return 0 + pid=$(systemctl show --property MainPID --value "$SESMAN_SERVICE" 2>/dev/null) + if [[ "$pid" =~ ^[0-9]+$ ]] && [ "$pid" -gt 1 ]; then + if kill -HUP "$pid" 2>/dev/null; then + echo "Asked xrdp-sesman to reload $SESMAN_INI." + else + echo "WARNING: Could not signal xrdp-sesman. It uses the new $SESMAN_INI once restarted." + fi + fi +} + +install_launcher() { + local current configured="startwm.sh" add_key=1 original="" backup_value + + set -u + umask 022 + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + trap '[ -z "$SESMAN_TMP" ] || rm -f "$SESMAN_TMP"' EXIT + + [ "$(id -u)" -eq 0 ] || fail "--install must run as root." + [ -x "$LAUNCHER_PATH" ] || fail "$LAUNCHER_PATH is missing or not executable." + if [ ! -f "$SESMAN_INI" ]; then + echo "xrdp is not installed: $SESMAN_INI does not exist." + exit 3 + fi + + current=$(read_default_wm "$SESMAN_INI") || fail "Could not read $SESMAN_INI." + if [ -n "$current" ]; then + add_key=0 + configured="${current#=}" + # xrdp's own default when the value is empty. + [ -n "$configured" ] || configured="startwm.sh" + fi + + if is_launcher "$configured"; then + # Already installed: only make sure the script it falls back to is still there. + if ! original=$(recorded_wm) && [ -f "$SESMAN_BACKUP" ]; then + backup_value=$(read_default_wm "$SESMAN_BACKUP") || backup_value="" + backup_value="${backup_value#=}" + original=$(resolve_wm "${backup_value:-startwm.sh}") || original="" + fi + [ -n "$original" ] || original=$(first_fallback_wm) || fail "No xrdp session script was found to fall back to." + write_state "$original" || fail "Could not write $STATE_FILE." + echo "xrdp already starts sessions through $LAUNCHER_PATH, which falls back to $original." + else + original=$(resolve_wm "$configured") || original=$(first_fallback_wm) || fail "No xrdp session script was found to fall back to." + SESMAN_TMP=$(mktemp "$SESMAN_INI.linuxbroker.XXXXXX") || fail "Could not update $SESMAN_INI." + rewrite_sesman "$SESMAN_INI" "$add_key" > "$SESMAN_TMP" || fail "$SESMAN_INI has no [Globals] section." + # The state first, so a session that starts in between already finds its script. + write_state "$original" || fail "Could not write $STATE_FILE." + if [ ! -e "$SESMAN_BACKUP" ]; then + cp -p "$SESMAN_INI" "$SESMAN_BACKUP" || fail "Could not back up $SESMAN_INI." + fi + if ! chmod --reference="$SESMAN_INI" "$SESMAN_TMP" || ! chown --reference="$SESMAN_INI" "$SESMAN_TMP" \ + || ! mv -f "$SESMAN_TMP" "$SESMAN_INI"; then + fail "Could not update $SESMAN_INI." + fi + SESMAN_TMP="" + restore_context "$SESMAN_INI" + echo "xrdp now starts sessions through $LAUNCHER_PATH, which falls back to $original." + reload_sesman + fi + + install_polkit_rule || fail "Could not write $POLKIT_RULE_FILE." + exit 0 +} + +log_session() { + if command -v logger >/dev/null 2>&1; then + logger -t linuxbroker-startwm -- "$1" >/dev/null 2>&1 || true + fi +} + +# The desktop desktop.conf names, or nothing when it names none this script starts. The file +# is read, never sourced. +configured_desktop() { + local value + + [ -r "$DESKTOP_FILE" ] || return 0 + value=$(sed -n 's/^[[:space:]]*DESKTOP[[:space:]]*=//p' "$DESKTOP_FILE" 2>/dev/null | tail -n 1) + value=$(printf '%s' "$value" | tr -d "\"' \t\r" | tr '[:upper:]' '[:lower:]') + case "$value" in + gnome|xfce|mate) printf '%s\n' "$value" ;; + "") ;; + *) log_session "Ignoring DESKTOP=$value in $DESKTOP_FILE: expected gnome, xfce or mate." ;; + esac +} + +# Starts the desktop through Debian's Xsession, after the same profiles xrdp's own script +# reads. Returns when the desktop is not installed. +start_debian_desktop() { + local startup + + case "$1" in + gnome) + command -v gnome-session >/dev/null 2>&1 || return 1 + if [ -f "$UBUNTU_SESSION_FILE" ]; then + # What GDM sets for "Ubuntu on Xorg": Ubuntu's session, theme and dock. + export DESKTOP_SESSION=ubuntu XDG_SESSION_DESKTOP=ubuntu XDG_CURRENT_DESKTOP=ubuntu:GNOME GNOME_SHELL_SESSION_MODE=ubuntu + startup="gnome-session --session=ubuntu" + else + export DESKTOP_SESSION=gnome XDG_SESSION_DESKTOP=gnome XDG_CURRENT_DESKTOP=GNOME + startup="gnome-session" + fi + ;; + *) + return 1 + ;; + esac + + # gnome-session starts the X11 flavor of its systemd units from this. + export XDG_SESSION_TYPE=x11 + log_session "Starting $1 for $(id -un 2>/dev/null) with: $startup" + # shellcheck disable=SC2016 # expanded by the inner shell + exec /bin/sh -c 'linuxbroker_startup=$1 +if test -r /etc/profile; then . /etc/profile; fi +if test -r "$HOME/.profile"; then . "$HOME/.profile"; fi +exec /etc/X11/Xsession "$linuxbroker_startup"' linuxbroker-startwm "$startup" +} + +run_original() { + local original + + original=$(recorded_wm) || original=$(first_fallback_wm) || original="" + if [ -n "$original" ]; then + exec "$original" + fi + + log_session "No xrdp session script was found, so the X session is started directly." + if [ -x /etc/X11/Xsession ]; then + exec /etc/X11/Xsession + fi + if [ -x /etc/X11/xinit/Xsession ]; then + exec /etc/X11/xinit/Xsession + fi + log_session "No X session script was found either, so the session cannot start." + exit 1 +} + +start_session() { + local desktop + + desktop=$(configured_desktop) + if [ -n "$desktop" ] && [ -d /etc/X11/Xsession.d ] && [ -x /etc/X11/Xsession ]; then + start_debian_desktop "$desktop" + log_session "$desktop is not installed, so the distribution's session script is used." + fi + run_original +} + +case "${1:-}" in + --install) + [ $# -eq 1 ] || usage + install_launcher + ;; + *) + start_session + ;; +esac From 7606a6a2856bccc27df98239dd424459fe2a67bb Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 17:51:50 -0400 Subject: [PATCH 06/19] Let each deployment choose GNOME, Xfce or MATE for its Linux hosts A new linuxHostDesktop parameter, gnome by default, chooses the desktop the Linux hosts run in xrdp sessions. The bootstraps install Xfce or MATE from EPEL on RHEL and from Ubuntu's own packages on Ubuntu, without a display manager, and record the choice in /etc/linuxbroker/desktop.conf. Bicep passes LINUXBROKER_DESKTOP only for xfce and mate, so the extension command, and with it every existing GNOME host, is unchanged. xrdp-startwm.sh starts Xfce and MATE with the variables a display manager would set, through Debian's Xsession or the xinit Xsession of RHEL, and runs the distribution's script when the desktop is missing. apply-host-settings.sh adds the MATE keys and locks to the dconf policy and, on Xfce hosts, writes a system xfconf file for xfce4-screensaver whose properties only root may change when the settings are locked. Both desktops count the delays in minutes, up to 8 hours, so the blank delay rounds up and the lock delay to the nearest minute. The heartbeat reports the configured desktop, and Host Settings explains the rounding when the fleet has Xfce or MATE hosts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 18 ++- .../Configure-RHEL8-Host.sh | 67 ++++++++-- .../Configure-RHEL9-Host.sh | 60 +++++++-- .../Configure-Ubuntu24_desktop-Host.sh | 63 ++++++--- deploy/DEPLOYMENT.md | 64 +++++++-- deploy/Initialize-DeploymentEnvironment.ps1 | 2 + deploy/bicep/main.bicep | 11 +- deploy/bicep/main.json | 57 +++++++- deploy/bicep/main.parameters.example.json | 3 + deploy/bicep/main.resources.bicep | 11 +- deploy/bicep/modules/Linux/main.bicep | 15 ++- front_end/web/src/App.test.tsx | 26 +++- front_end/web/src/lib/desktops.test.ts | 16 +++ front_end/web/src/lib/desktops.ts | 13 ++ .../web/src/pages/settings/HostSettings.tsx | 24 +++- linux_host/apply-host-settings.sh | 105 ++++++++++++++- .../session_release_buffer/release-session.sh | 31 +++-- linux_host/tests/test_apply_host_settings.sh | 124 +++++++++++++++++- linux_host/tests/test_heartbeat.sh | 17 +++ linux_host/tests/test_xrdp_startwm.sh | 101 +++++++++++++- linux_host/xrdp-startwm.sh | 52 ++++++-- 21 files changed, 769 insertions(+), 111 deletions(-) create mode 100644 front_end/web/src/lib/desktops.test.ts create mode 100644 front_end/web/src/lib/desktops.ts diff --git a/README.md b/README.md index 33cc332..24892bc 100644 --- a/README.md +++ b/README.md @@ -186,12 +186,20 @@ The custom script extension for the AVD host: The custom script extensions support the following Linux distributions: - **Red Hat Enterprise Linux (RHEL) 8 and 9** -- **Ubuntu 24.04**: Canonical's server image with the Ubuntu desktop added, which xrdp sessions run as Ubuntu on Xorg +- **Ubuntu 24.04**: Canonical's server image, with a desktop added + +Each deployment chooses the desktop its hosts run with `linuxHostDesktop`: + +- **GNOME**, the default: the `Server with GUI` group on RHEL, and on Ubuntu the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg +- **Xfce** +- **MATE** + +On RHEL, Xfce and MATE come from EPEL. These scripts: - **Install XRDP and xpra**: Set up XRDP for full desktop access (RDP) and xpra for application virtualization, enabling users to connect via AVD. -- **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the host's GNOME desktop, as Ubuntu on Xorg on Ubuntu. +- **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the desktop the deployment chose. - **Configure Authentication**: Sets up authentication mechanisms for secure user access. - **Deploy the Linux Session Release Agent**: Installs the timer-based reconciliation service plus a `systemd-logind` watcher that can trigger early reconciliations. The timer remains the fallback path so the system still converges even if event delivery is delayed or unavailable. - **Install the Host Settings Agent**: Installs `apply-host-settings.sh` and seeds the settings profile, so screen lock policy and session timings are applied consistently on every supported distribution rather than only on RHEL 8. `LINUXBROKER_DISABLE_SCREEN_LOCK` still chooses the screen lock posture that is seeded; from then on the values are managed from the portal. @@ -233,16 +241,18 @@ Administrators manage host behavior from the **Host Settings** page in the Servi | Watcher settle | 2 s | 0–60 | Pause after a `logind` signal before reconciling | | Idle timeout | 0 (disabled) | 0, or 300–86400 | Inactivity before a connected user is disconnected | | Idle warning lead time | 120 s | 0–900 | On-screen warning before the idle timeout, must be less than the timeout | -| Remove the lock screen | true | boolean | Disables the Super+L shortcut and the Lock menu entry | +| Remove the lock screen | true | boolean | Stops the screen from locking at all. On GNOME it also removes the Super+L shortcut and the Lock menu entry | | Screen lock enabled | false | boolean | Whether the screen locks when the screensaver activates | | Screen blank delay | 0 (never) | 0–86400 | Inactivity before the screen blanks | | Screen lock delay | 0 (immediate) | 0–86400 | Delay between blanking and locking | -| Lock screen settings | true | boolean | Applies dconf locks so users cannot override the screen lock values | +| Lock screen settings | true | boolean | Locks the screen lock values in dconf, and in xfconf on Xfce hosts, so users cannot override them | The session lifecycle defaults match the values that were previously hardcoded, so adopting this feature changes no behavior until an administrator edits the profile. The screen lock defaults preserve the posture set by `LINUXBROKER_DISABLE_SCREEN_LOCK`: the lock screen is removed, because a locked GNOME greeter inside an xrdp session frequently cannot be unlocked after a reconnect, which strands the host's lease. That environment variable still chooses the posture seeded at provisioning time; from then on the values are managed from the portal. Set **Screen lock enabled** on and **Remove the lock screen** off to satisfy a STIG or CIS idle-lock control. +The same values apply to every desktop. Xfce and MATE count the screen blank and lock delays in whole minutes, up to 8 hours, so the blank delay is rounded up and the lock delay to the nearest minute, and Xfce sessions pick up a change when they start. **Host Settings** notes this when the fleet has Xfce or MATE hosts. See [Linux Host Screen Lock](deploy/DEPLOYMENT.md#linux-host-screen-lock) for the files each desktop reads. + **Keep sessions alive during the grace period** and **Screen lock enabled** are mutually exclusive: a resumed session behind a lock screen cannot be unlocked, because users never know the password the broker sets at each checkout. Hosts that have not been updated with `deploy/Migrate-LinuxHostReleaseAgent.ps1` keep closing desktops at disconnect and show as pending in the drift table once the setting is on. #### How settings reach the hosts diff --git a/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index d36739a..168c4cd 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -68,7 +68,7 @@ xrdp_ini="/etc/xrdp/xrdp.ini" arch=$( /bin/arch ) remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" -# Disable the GNOME screen saver and screen lock on this host. Enabled by default because a +# Disable the screen saver and screen lock on this host. Enabled by default because a # locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" @@ -83,6 +83,19 @@ case "$disableScreenLock" in ;; esac +# The desktop xrdp sessions run: gnome, the Server with GUI group, or xfce or mate, both from +# EPEL. Bicep sets LINUXBROKER_DESKTOP only for xfce and mate. +desktop="${LINUXBROKER_DESKTOP:-gnome}" +desktop=$(printf '%s' "$desktop" | tr '[:upper:]' '[:lower:]') + +case "$desktop" in + gnome|xfce|mate) ;; + *) + echo "Unsupported LINUXBROKER_DESKTOP value: $desktop (expected gnome, xfce or mate)" + exit 1 + ;; +esac + orgId="${RHEL_ORG_ID:-}" activationKey="${RHEL_ACTIVATION_KEY:-}" @@ -129,7 +142,35 @@ sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf # install it must still finish provisioning rather than fail the extension. echo "Installing idle detection support..." sudo dnf install -y xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." -sudo dnf groupinstall -y "Server with GUI" + +case "$desktop" in + gnome) + sudo dnf groupinstall -y "Server with GUI" + ;; + xfce) + # GDM is left out, as it brings GNOME Shell with it and xrdp needs no display manager. + # xfce4-screensaver is the screen saver the host settings configure, and GNOME Keyring + # keeps passwords for applications as it does on the other desktops. + echo "Installing the Xfce desktop..." + if ! sudo dnf install -y --exclude=gdm @base-x @xfce-desktop xfce4-screensaver xfce4-notifyd \ + gnome-keyring gnome-keyring-pam; then + echo "ERROR: Could not install the Xfce desktop." + exit 1 + fi + ;; + mate) + echo "Installing the MATE desktop..." + if ! sudo dnf install -y @base-x mate-session-manager mate-panel marco caja mate-settings-daemon \ + mate-control-center mate-terminal mate-screensaver mate-notification-daemon mate-polkit \ + mate-power-manager mate-desktop mate-menus mate-themes mate-icon-theme mate-backgrounds \ + mate-media pluma eom engrampa; then + echo "ERROR: Could not install the MATE desktop." + exit 1 + fi + # Atril, the document viewer, needs a package from CodeReady Builder on RHEL 8. + sudo dnf install -y atril || echo "Atril is unavailable without CodeReady Builder, so MATE has no document viewer on this host." + ;; +esac case "$remoteAccessTool" in "xrdp") @@ -330,15 +371,15 @@ echo "Downloading xrdp-startwm.sh..." sudo wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" sudo chmod +x "$xrdp_startwm_script" -# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. -# On RHEL that is the distribution's own session script, as before. +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. For +# GNOME that is the distribution's own session script, as before. echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." sudo mkdir -p "$(dirname "$desktop_file")" sudo chmod 755 "$(dirname "$desktop_file")" -cat <<'EOF' | sudo tee "$desktop_file" >/dev/null +cat </dev/null # Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every # xrdp session. -DESKTOP=gnome +DESKTOP=$desktop EOF sudo chmod 644 "$desktop_file" @@ -369,16 +410,16 @@ fi # Note: public ssh key is still needed for avdadmin echo "avdadmin user is created and permissioned" -# Seed the Linux Broker host settings profile. This writes the dconf screen lock policy, -# the dconf profile that makes it take effect, the release agent's settings file, and the -# systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK still -# chooses the screen lock posture; from here on the values are managed from the portal and -# the release agent converges the host to the configured profile on its next run. +# Seed the Linux Broker host settings profile. This writes the screen lock policy for each +# desktop, the dconf profile that makes it take effect, the release agent's settings file, +# and the systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK +# still chooses the screen lock posture; from here on the values are managed from the portal +# and the release agent converges the host to the configured profile on its next run. if [ "$disableScreenLock" = "true" ]; then - echo "Seeding host settings with the Gnome Desktop screen saver and screen lock disabled..." + echo "Seeding host settings with the screen saver and screen lock disabled..." settings_seed='{"ScreenLockEnabled":false,"DisableLockScreen":true}' else - echo "Seeding host settings with the Gnome Desktop screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." + echo "Seeding host settings with the screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." settings_seed='{"ScreenLockEnabled":true,"DisableLockScreen":false}' fi diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index b7294d1..96431c1 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -48,7 +48,7 @@ xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" arch=$( /bin/arch ) remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" -# Disable the GNOME screen saver and screen lock on this host. Enabled by default because a +# Disable the screen saver and screen lock on this host. Enabled by default because a # locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" @@ -63,6 +63,19 @@ case "$disableScreenLock" in ;; esac +# The desktop xrdp sessions run: gnome, the Server with GUI group, or xfce or mate, both from +# EPEL. Bicep sets LINUXBROKER_DESKTOP only for xfce and mate. +desktop="${LINUXBROKER_DESKTOP:-gnome}" +desktop=$(printf '%s' "$desktop" | tr '[:upper:]' '[:lower:]') + +case "$desktop" in + gnome|xfce|mate) ;; + *) + echo "Unsupported LINUXBROKER_DESKTOP value: $desktop (expected gnome, xfce or mate)" + exit 1 + ;; +esac + orgId="${RHEL_ORG_ID:-}" activationKey="${RHEL_ACTIVATION_KEY:-}" @@ -124,8 +137,27 @@ sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf echo "Installing idle detection support..." sudo dnf install -y xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." -echo "Installing 'Server with GUI' group..." -sudo dnf groupinstall -y "Server with GUI" +case "$desktop" in + gnome) + echo "Installing 'Server with GUI' group..." + sudo dnf groupinstall -y "Server with GUI" + ;; + xfce) + # GDM is left out, as it brings GNOME Shell with it and xrdp needs no display manager. + # xfce4-screensaver is the screen saver the host settings configure, and GNOME Keyring + # keeps passwords for applications as it does on the other desktops. + echo "Installing the Xfce desktop..." + sudo dnf install -y --exclude=gdm @base-x @xfce-desktop xfce4-screensaver xfce4-notifyd \ + gnome-keyring gnome-keyring-pam + ;; + mate) + echo "Installing the MATE desktop..." + sudo dnf install -y @base-x mate-session-manager mate-panel marco caja mate-settings-daemon \ + mate-control-center mate-terminal mate-screensaver mate-notification-daemon mate-polkit \ + mate-power-manager mate-desktop mate-menus mate-themes mate-icon-theme mate-backgrounds \ + mate-media pluma atril eom engrampa + ;; +esac case "$remoteAccessTool" in "xrdp"|"xpra"|"both") @@ -259,15 +291,15 @@ sudo chmod +x "$patch_host_script" sudo chmod +x "$xrdp_startwm_script" echo "Downloaded scripts are now executable." -# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. -# On RHEL that is the distribution's own session script, as before. +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. For +# GNOME that is the distribution's own session script, as before. echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." sudo mkdir -p "$(dirname "$desktop_file")" sudo chmod 755 "$(dirname "$desktop_file")" -cat <<'EOF' | sudo tee "$desktop_file" >/dev/null +cat </dev/null # Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every # xrdp session. -DESKTOP=gnome +DESKTOP=$desktop EOF sudo chmod 644 "$desktop_file" @@ -386,16 +418,16 @@ else fi echo "avdadmin user is created and permissioned" -# Seed the Linux Broker host settings profile. This writes the dconf screen lock policy, -# the dconf profile that makes it take effect, the release agent's settings file, and the -# systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK still -# chooses the screen lock posture; from here on the values are managed from the portal and -# the release agent converges the host to the configured profile on its next run. +# Seed the Linux Broker host settings profile. This writes the screen lock policy for each +# desktop, the dconf profile that makes it take effect, the release agent's settings file, +# and the systemd drop-ins, then compiles the dconf database. LINUXBROKER_DISABLE_SCREEN_LOCK +# still chooses the screen lock posture; from here on the values are managed from the portal +# and the release agent converges the host to the configured profile on its next run. if [ "$disableScreenLock" = "true" ]; then - echo "Seeding host settings with the Gnome Desktop screen saver and screen lock disabled..." + echo "Seeding host settings with the screen saver and screen lock disabled..." settings_seed='{"ScreenLockEnabled":false,"DisableLockScreen":true}' else - echo "Seeding host settings with the Gnome Desktop screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." + echo "Seeding host settings with the screen lock left enabled (LINUXBROKER_DISABLE_SCREEN_LOCK=false)." settings_seed='{"ScreenLockEnabled":true,"DisableLockScreen":false}' fi diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index a1c5ddf..6d8631c 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -1,8 +1,8 @@ #!/bin/bash # Installs and configures the necessary packages for Linux Broker for AVD Access on Ubuntu -# 24.04: the Ubuntu desktop, which xrdp sessions run as "Ubuntu on Xorg", and the Linux -# Broker host agent. The Custom Script Extension runs it as root. +# 24.04: the desktop, by default the Ubuntu desktop, which xrdp sessions run as "Ubuntu on +# Xorg", and the Linux Broker host agent. The Custom Script Extension runs it as root. LINUXBROKER_API_BASE_URL="${1:-}" LINUXBROKER_API_CLIENT_ID="${2:-}" @@ -47,7 +47,7 @@ patch_host_script="/usr/local/bin/patch-host.sh" xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" -# Disable the GNOME screen saver and screen lock on this host. Enabled by default because a +# Disable the screen saver and screen lock on this host. Enabled by default because a # locked greeter inside an xrdp session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" @@ -62,6 +62,19 @@ case "$disableScreenLock" in ;; esac +# The desktop xrdp sessions run: gnome, the Ubuntu desktop, xfce or mate. Bicep sets +# LINUXBROKER_DESKTOP only for xfce and mate. +desktop="${LINUXBROKER_DESKTOP:-gnome}" +desktop=$(printf '%s' "$desktop" | tr '[:upper:]' '[:lower:]') + +case "$desktop" in + gnome|xfce|mate) ;; + *) + echo "Unsupported LINUXBROKER_DESKTOP value: $desktop (expected gnome, xfce or mate)" + exit 1 + ;; +esac + output_directory="/usr/local/bin" state_directory="/var/lib/linuxbroker-release-session" desktop_file="/etc/linuxbroker/desktop.conf" @@ -117,15 +130,29 @@ apt_update apt_get -y --with-new-pkgs upgrade # The first-login wizard would greet every broker user, and crash reports are not collected -# (see apport below), so gnome-initial-setup and whoopsie are left out. Firefox is a snap on -# Ubuntu, and a snap store that cannot be reached would fail the whole install, so it is -# installed on its own afterwards. -desktop_packages=(ubuntu-desktop-minimal gnome-initial-setup- whoopsie-) +# (see apport below), so gnome-initial-setup and whoopsie are left out. xrdp needs no display +# manager, so Xfce and MATE come without LightDM, and without light-locker, which locks the +# screen through it. xfce4-screensaver is the screen saver the host settings configure, and +# GNOME Keyring keeps passwords for applications as it does on the other desktops. Firefox is +# a snap on Ubuntu, and a snap store that cannot be reached would fail the whole install, so +# it is installed on its own afterwards. +case "$desktop" in + gnome) + desktop_packages=(ubuntu-desktop-minimal gnome-initial-setup- whoopsie-) + ;; + xfce) + desktop_packages=(xfce4 xfce4-goodies xfce4-screensaver xfce4-notifyd gnome-keyring libpam-gnome-keyring + lightdm- light-locker-) + ;; + mate) + desktop_packages=(mate-desktop-environment-core mate-screensaver mate-notification-daemon lightdm-) + ;; +esac if ! dpkg-query -W -f='${Status}' firefox 2>/dev/null | grep -q 'install ok installed'; then desktop_packages+=(firefox-) fi -echo "Installing the Ubuntu desktop, xrdp and the Linux Broker dependencies..." +echo "Installing the desktop, xrdp and the Linux Broker dependencies..." apt_get -y install jq nfs-common dconf-cli curl wget ufw libnotify-bin x11-utils dbus-user-session \ xrdp xorgxrdp "${desktop_packages[@]}" @@ -230,13 +257,13 @@ chmod +x "$xrdp_startwm_script" echo "Downloaded scripts are now executable." # xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. -echo "Configuring xrdp to start the Ubuntu desktop..." +echo "Configuring xrdp to start the desktop..." mkdir -p "$(dirname "$desktop_file")" chmod 755 "$(dirname "$desktop_file")" -cat > "$desktop_file" <<'EOF' +cat > "$desktop_file" <.` from the API's virtual network. @@ -237,31 +238,45 @@ If you prefer to be prompted locally, leave both values unset and run `azd up` f ## Linux Host Screen Lock -RHEL hosts install the `Server with GUI` group and Ubuntu hosts install the Ubuntu desktop, so -both run a GNOME desktop. By default the bootstrap script disables the GNOME screen saver and -screen lock on those hosts. +Linux hosts run GNOME unless `linuxHostDesktop` chooses Xfce or MATE. By default the bootstrap +script disables the screen saver and screen lock on those hosts, whichever desktop they run. This is on by default because a locked GNOME greeter inside an xrdp or xpra session frequently cannot be unlocked after a reconnect. When that happens the user cannot get back into the -desktop, and the host stays leased until the lease is released manually. +desktop, and the host stays leased until the lease is released manually. Xfce and MATE hosts get +the same default, so the posture does not depend on the desktop a deployment chose. -The configuration is applied through a dconf system database: +The configuration is applied through a dconf system database, which GNOME and MATE read, and on +Xfce hosts through a system xfconf file: | File on the host | Written by | | --- | --- | | `/etc/dconf/db/local.d/00-screensaver` | [linux_host/apply-host-settings.sh](../linux_host/apply-host-settings.sh) | | `/etc/dconf/db/local.d/locks/screensaver` | [linux_host/apply-host-settings.sh](../linux_host/apply-host-settings.sh) | | `/etc/dconf/profile/user` | [linux_host/apply-host-settings.sh](../linux_host/apply-host-settings.sh) | +| `/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml`, on Xfce hosts | [linux_host/apply-host-settings.sh](../linux_host/apply-host-settings.sh) | These files were previously static and downloaded during bootstrap. They are now generated from the fleet-wide host settings profile, which is what makes the values editable in the portal after deployment. The bootstrap seeds that profile once, and the release agent keeps each host converged to it from then on. See [Linux Host Settings](../README.md#linux-host-settings). -It sets `idle-delay` to `0` so the session never goes idle, sets `lock-enabled` to `false` so -the screen saver never locks, and sets `disable-lock-screen` to `true` so the lock screen is -removed entirely, including the `Super+L` shortcut and the `Lock` entry in the system menu. The -lock list prevents users from changing any of those keys back. +On GNOME it sets `idle-delay` to `0` so the session never goes idle, sets `lock-enabled` to +`false` so the screen saver never locks, and sets `disable-lock-screen` to `true` so the lock +screen is removed entirely, including the `Super+L` shortcut and the `Lock` entry in the system +menu. The same database sets the matching MATE keys under `org/mate`, where +`disable-lock-screen` in `org/mate/desktop/lockdown` stops MATE from locking the screen. On Xfce +the xfconf file turns off xfce4-screensaver's blanking and locking; Xfce keeps its `Lock Screen` +entry, which does nothing while locking is disabled. While **Prevent users from changing these +screen lock settings** is on in **Host Settings**, as it is by default, the lock list stops users +from changing any of the GNOME and MATE keys back, and each property in the xfconf file is marked +`unlocked="root"`, so Xfce ignores the values users set for themselves. + +GNOME counts the blank and lock delays in seconds, as the portal does. MATE and Xfce count them +in whole minutes, up to 8 hours, so the delays are converted for them: the blank delay rounds up, +so a delay of a few seconds does not turn blanking off, and the lock delay rounds to the nearest +minute. Xfce reads the file when a session starts, so a change reaches the Xfce sessions that +start after it. RHEL does not ship `/etc/dconf/profile/user`, and a system dconf database is only read when a profile references it, so the bootstrap creates that file with `system-db:local`. An existing @@ -290,14 +305,24 @@ deployment from **Host Settings** in the portal, without redeploying anything. ls -l /etc/dconf/db/local grep system-db /etc/dconf/profile/user -# The effective values, from inside a desktop session. +# The effective values, from inside a GNOME session. gsettings get org.gnome.desktop.session idle-delay gsettings get org.gnome.desktop.screensaver lock-enabled gsettings get org.gnome.desktop.lockdown disable-lock-screen + +# From inside a MATE session. +gsettings get org.mate.session idle-delay +gsettings get org.mate.screensaver lock-enabled +gsettings get org.mate.lockdown disable-lock-screen + +# From inside an Xfce session. +xfconf-query -c xfce4-screensaver -p /saver/enabled +xfconf-query -c xfce4-screensaver -p /lock/enabled ``` -Expect `uint32 0`, `false`, and `true`. If `gsettings` still reports the distribution defaults, -check that `/etc/dconf/profile/user` contains `system-db:local` and rerun `sudo dconf update`. +Expect `uint32 0`, `false`, and `true` on GNOME, `0`, `false`, and `true` on MATE, and `false` +twice on Xfce. If `gsettings` still reports the distribution defaults, check that +`/etc/dconf/profile/user` contains `system-db:local` and rerun `sudo dconf update`. ## Quick Start @@ -366,7 +391,7 @@ Important deployment characteristics: - The API's `NFS_SHARE` setting points at the provisioned Azure Files share unless `nfsShare` is set. The storage account disables public network access and shared key access, and it allows non-HTTPS traffic because NFS does not use HTTPS; the private endpoint is the only path to it. - RHEL hosts use Generation 2 images so they can run with Trusted Launch. - The AVD host pool prefers RemoteApp and sets RDP properties that enable Microsoft Entra single sign-on to the Microsoft Entra joined session hosts. -- Linux hosts have the GNOME screen saver and screen lock disabled unless `linuxHostDisableScreenLock` is `false`. See [Linux Host Screen Lock](#linux-host-screen-lock). +- Linux hosts run the desktop that `linuxHostDesktop` names, GNOME by default, with the screen saver and screen lock disabled unless `linuxHostDisableScreenLock` is `false`. See [Linux Host Screen Lock](#linux-host-screen-lock). - Key Vault stores `db-password` and `linux-host`. - The API app receives Key Vault Secrets User access so it can read those secrets at runtime. @@ -569,6 +594,8 @@ This release changes which Linux distributions and desktops the deployment offer az vm run-command invoke -g -n --command-id RunShellScript --scripts "curl -fsSL -o /tmp/linuxbroker-bootstrap.sh $root/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh && LINUXBROKER_SCRIPT_SOURCE_ROOT=$root bash /tmp/linuxbroker-bootstrap.sh $api $clientId" ``` +- **Hosts can run Xfce or MATE.** The new `linuxHostDesktop` parameter chooses the desktop: `gnome`, the default and the only desktop until now, `xfce` or `mate`. The bootstrap installs it, from EPEL on RHEL and from Ubuntu's own packages on Ubuntu, and records it in `/etc/linuxbroker/desktop.conf`. `xrdp-startwm.sh` starts the desktop named there, and the heartbeat reports it in **Fleet health**. The host settings apply to all three desktops; see [Linux Host Screen Lock](#linux-host-screen-lock) for how MATE and Xfce count the screen delays in minutes and when Xfce sessions pick up a change. With `gnome` the extension command is unchanged, so an environment that keeps the default sees no change to its hosts. Changing the value changes the extension command, so the next `azd provision` runs the bootstrap again on existing hosts. It adds the new desktop next to the old one, and the sessions that start afterwards use the new desktop. Drain the hosts first, because the bootstrap also updates every package and reinstalls the release agent, and on Ubuntu it restarts xrdp. To choose Xfce or MATE when you run a bootstrap script by hand, set `LINUXBROKER_DESKTOP=xfce` or `LINUXBROKER_DESKTOP=mate` in its environment. + ## Manual Steps After `azd up` ### Admin consent @@ -807,6 +834,15 @@ The system proxy service installed by the upstream xpra 6.5 packages exits durin The GNOME lock screen inside an xrdp or xpra session often cannot be unlocked after a reconnect. Confirm the screen lock configuration actually applied on the host using the commands in [Linux Host Screen Lock](#linux-host-screen-lock). The most common cause is a missing `system-db:local` line in `/etc/dconf/profile/user`, which makes GNOME ignore the settings even though the files under `/etc/dconf/db/local.d/` are present. +### A session starts a different desktop than `linuxHostDesktop` names + +`xrdp-startwm.sh` starts the desktop named in `/etc/linuxbroker/desktop.conf` and logs each start under the `linuxbroker-startwm` tag. When that desktop is not installed, it runs the distribution's own session script instead and logs that too. A host that was migrated rather than bootstrapped has no `desktop.conf`, so it always runs the distribution's session script. Drain such a host and run its bootstrap again, which installs the desktop and writes the file: + +```bash +cat /etc/linuxbroker/desktop.conf +sudo journalctl -t linuxbroker-startwm -n 20 +``` + ### The Custom Script Extension failed on the screen lock step The bootstrap fails deliberately if the host settings cannot be applied, so the problem is visible instead of silently leaving the lock screen enabled. Check that `scriptSourceRoot` is reachable from the host so `apply-host-settings.sh` can be downloaded, or set `linuxHostDisableScreenLock` to `false` to seed the profile with the lock screen left enabled. diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index 6ac3cdf..ca10c6b 100644 --- a/deploy/Initialize-DeploymentEnvironment.ps1 +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -1056,6 +1056,7 @@ Ensure-DefaultEnvValue -Key 'linuxHostSshPublicKey' -ValueFactory { Get-AzdEnvVa Ensure-DefaultEnvValue -Key 'linuxHostSshPrivateKey' -ValueFactory { Get-AzdEnvValue -Key 'LINUX_HOST_SSH_PRIVATE_KEY' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostOsVersion' -ValueFactory { '9-LVM' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostDisableScreenLock' -ValueFactory { 'true' } | Out-Null +Ensure-DefaultEnvValue -Key 'linuxHostDesktop' -ValueFactory { 'gnome' } | Out-Null Ensure-DefaultEnvValue -Key 'linuxHostVmSize' -ValueFactory { 'Standard_D2s_v5' } | Out-Null Ensure-DefaultEnvValue -Key 'avdVmSize' -ValueFactory { 'Standard_D8s_v5' } | Out-Null Ensure-DefaultEnvValue -Key 'avdMaxSessionLimit' -ValueFactory { '5' } | Out-Null @@ -1236,6 +1237,7 @@ Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterNa Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostAuthType' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostAuthType') Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostOsVersion' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostOsVersion') Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostDisableScreenLock' -Value (ConvertTo-BoolParameterValue -Key 'linuxHostDisableScreenLock' -DefaultValue $true) +Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'linuxHostDesktop' -Value (Get-RequiredAzdEnvValue -Key 'linuxHostDesktop').Trim().ToLowerInvariant() Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdHostPoolName' -Value (Get-RequiredAzdEnvValue -Key 'avdHostPoolName') Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdSessionHostCount' -Value (ConvertTo-IntParameterValue -Key 'avdSessionHostCount') Add-BicepParameterValue -ParameterCollection $bicepParameterEntries -ParameterName 'avdMaxSessionLimit' -Value (ConvertTo-IntParameterValue -Key 'avdMaxSessionLimit' -DefaultValue 5) diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index cbc4e94..702ba33 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -150,9 +150,17 @@ param linuxHostSshPublicKey string = '' @description('Linux host OS image SKU.') param linuxHostOsVersion string = '9-LVM' -@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') +@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param linuxHostDisableScreenLock bool = true +@allowed([ + 'gnome' + 'xfce' + 'mate' +]) +@description('Desktop the Linux hosts run in xrdp sessions: gnome (the RHEL Server with GUI group, or the Ubuntu desktop), xfce or mate. Changing it on existing hosts runs their bootstrap again at the next provision, so drain them first.') +param linuxHostDesktop string = 'gnome' + @description('AVD host pool name.') param avdHostPoolName string = '' @@ -235,6 +243,7 @@ module resources 'main.resources.bicep' = { linuxHostSshPublicKey: linuxHostSshPublicKey linuxHostOsVersion: linuxHostOsVersion linuxHostDisableScreenLock: linuxHostDisableScreenLock + linuxHostDesktop: linuxHostDesktop avdHostPoolName: avdHostPoolName avdSessionHostCount: avdSessionHostCount avdMaxSessionLimit: avdMaxSessionLimit diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 35277bb..a83e2db 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "5357812922203999908" + "templateHash": "7494438435876162804" } }, "parameters": { @@ -326,7 +326,19 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + } + }, + "linuxHostDesktop": { + "type": "string", + "defaultValue": "gnome", + "allowedValues": [ + "gnome", + "xfce", + "mate" + ], + "metadata": { + "description": "Desktop the Linux hosts run in xrdp sessions: gnome (the RHEL Server with GUI group, or the Ubuntu desktop), xfce or mate. Changing it on existing hosts runs their bootstrap again at the next provision, so drain them first." } }, "avdHostPoolName": { @@ -527,6 +539,9 @@ "linuxHostDisableScreenLock": { "value": "[parameters('linuxHostDisableScreenLock')]" }, + "linuxHostDesktop": { + "value": "[parameters('linuxHostDesktop')]" + }, "avdHostPoolName": { "value": "[parameters('avdHostPoolName')]" }, @@ -550,7 +565,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "13893275988389033575" + "templateHash": "1459326504640237068" } }, "parameters": { @@ -766,7 +781,19 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Set to false to keep the lock screen." + "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Set to false to keep the lock screen." + } + }, + "linuxHostDesktop": { + "type": "string", + "defaultValue": "gnome", + "allowedValues": [ + "gnome", + "xfce", + "mate" + ], + "metadata": { + "description": "Desktop the Linux hosts run in xrdp sessions." } }, "avdHostPoolName": { @@ -2830,6 +2857,9 @@ }, "disableScreenLock": { "value": "[parameters('linuxHostDisableScreenLock')]" + }, + "desktop": { + "value": "[parameters('linuxHostDesktop')]" } }, "template": { @@ -2839,7 +2869,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "9444761510623079381" + "templateHash": "8451551061526823506" } }, "parameters": { @@ -2913,7 +2943,19 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + } + }, + "desktop": { + "type": "string", + "defaultValue": "gnome", + "allowedValues": [ + "gnome", + "xfce", + "mate" + ], + "metadata": { + "description": "Desktop the hosts run in xrdp sessions. Changing it changes the extension command, which runs the bootstrap again on existing hosts." } } }, @@ -2927,7 +2969,8 @@ ], "normalizedScriptSourceRoot": "[if(endsWith(parameters('scriptSourceRoot'), '/'), take(parameters('scriptSourceRoot'), sub(length(parameters('scriptSourceRoot')), 1)), parameters('scriptSourceRoot'))]", "bootstrapArgs": "[format('\"{0}\" \"{1}\"', parameters('linuxBrokerApiBaseUrl'), parameters('linuxBrokerApiClientId'))]", - "bootstrapEnv": "[format('LINUXBROKER_SCRIPT_SOURCE_ROOT=\"{0}\" LINUXBROKER_DISABLE_SCREEN_LOCK=\"{1}\"', variables('normalizedScriptSourceRoot'), if(parameters('disableScreenLock'), 'true', 'false'))]", + "desktopEnv": "[if(equals(parameters('desktop'), 'gnome'), '', format(' LINUXBROKER_DESKTOP=\"{0}\"', parameters('desktop')))]", + "bootstrapEnv": "[format('LINUXBROKER_SCRIPT_SOURCE_ROOT=\"{0}\" LINUXBROKER_DISABLE_SCREEN_LOCK=\"{1}\"{2}', variables('normalizedScriptSourceRoot'), if(parameters('disableScreenLock'), 'true', 'false'), variables('desktopEnv'))]", "adminCredentials": "[if(equals(parameters('authType'), 'Password'), createObject('adminPassword', parameters('adminPassword')), createObject())]", "linuxConfiguration": "[if(equals(parameters('authType'), 'SSH'), createObject('disablePasswordAuthentication', true(), 'ssh', createObject('publicKeys', createArray(createObject('path', format('/home/{0}/.ssh/authorized_keys', parameters('adminUsername')), 'keyData', parameters('sshPublicKey'))))), createObject('disablePasswordAuthentication', false()))]", "imageConfigs": { diff --git a/deploy/bicep/main.parameters.example.json b/deploy/bicep/main.parameters.example.json index 364c988..62d8e53 100644 --- a/deploy/bicep/main.parameters.example.json +++ b/deploy/bicep/main.parameters.example.json @@ -128,6 +128,9 @@ "linuxHostDisableScreenLock": { "value": true }, + "linuxHostDesktop": { + "value": "gnome" + }, "avdHostPoolName": { "value": "linuxbroker--hp" }, diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 32c1251..4cbe016 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -87,9 +87,17 @@ param linuxHostSshPublicKey string = '' ]) param linuxHostOsVersion string = '9-LVM' -@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Set to false to keep the lock screen.') +@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Set to false to keep the lock screen.') param linuxHostDisableScreenLock bool = true +@allowed([ + 'gnome' + 'xfce' + 'mate' +]) +@description('Desktop the Linux hosts run in xrdp sessions.') +param linuxHostDesktop string = 'gnome' + param avdHostPoolName string = '' param avdSessionHostCount int = 0 param avdMaxSessionLimit int = 5 @@ -539,6 +547,7 @@ module linuxHosts 'modules/Linux/main.bicep' = if (deployLinuxHosts && linuxHost linuxBrokerApiClientId: apiClientId scriptSourceRoot: scriptSourceRoot disableScreenLock: linuxHostDisableScreenLock + desktop: linuxHostDesktop } } diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index f1190f5..0730192 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -33,12 +33,23 @@ param OSVersion string @description('Root URL the host bootstrap scripts are downloaded from. Point this at a reachable mirror for sovereign or air-gapped clouds.') param scriptSourceRoot string = 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main' -@description('Disable the GNOME screen saver and screen lock on the Linux hosts. Enabled by default because a locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') +@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param disableScreenLock bool = true +@allowed([ + 'gnome' + 'xfce' + 'mate' +]) +@description('Desktop the hosts run in xrdp sessions. Changing it changes the extension command, which runs the bootstrap again on existing hosts.') +param desktop string = 'gnome' + var normalizedScriptSourceRoot = endsWith(scriptSourceRoot, '/') ? take(scriptSourceRoot, length(scriptSourceRoot) - 1) : scriptSourceRoot var bootstrapArgs = '"${linuxBrokerApiBaseUrl}" "${linuxBrokerApiClientId}"' -var bootstrapEnv = 'LINUXBROKER_SCRIPT_SOURCE_ROOT="${normalizedScriptSourceRoot}" LINUXBROKER_DISABLE_SCREEN_LOCK="${disableScreenLock ? 'true' : 'false'}"' +// GNOME is what the bootstrap installs without LINUXBROKER_DESKTOP, so leaving the variable out +// keeps the extension command, and with it existing hosts, unchanged. +var desktopEnv = desktop == 'gnome' ? '' : ' LINUXBROKER_DESKTOP="${desktop}"' +var bootstrapEnv = 'LINUXBROKER_SCRIPT_SOURCE_ROOT="${normalizedScriptSourceRoot}" LINUXBROKER_DISABLE_SCREEN_LOCK="${disableScreenLock ? 'true' : 'false'}"${desktopEnv}' var vmNames = [for i in range(1, numberOfVMs): '${vmNamePrefix}-${padLeft(i, 2, '0')}'] var adminCredentials = authType == 'Password' ? { diff --git a/front_end/web/src/App.test.tsx b/front_end/web/src/App.test.tsx index 11caa3e..9d56f61 100644 --- a/front_end/web/src/App.test.tsx +++ b/front_end/web/src/App.test.tsx @@ -366,6 +366,7 @@ let session: typeof SESSION = SESSION; let dashboard: Omit & { stats: DashboardStats; fleetHealth?: unknown } = DASHBOARD; let trends: 'off' | 'on' = 'off'; let attention: unknown = NOTHING_NEEDS_ATTENTION; +let fleetHealth: unknown = FLEET_HEALTH; let maintenance: unknown = maintenancePage(); let legacyHostList = false; const requests: string[] = []; @@ -419,7 +420,7 @@ function stubFetch() { if (url.startsWith('/api/ui/scaling/schedules')) { return jsonResponse({ ScheduleID: 5, message: "Saved 'Evening'. It applies from the next scaling run." }); } - if (url.startsWith('/api/ui/hosts/health')) return jsonResponse(FLEET_HEALTH); + if (url.startsWith('/api/ui/hosts/health')) return jsonResponse(fleetHealth); if (url === '/api/ui/hosts/settings/apply') { return jsonResponse({ settingsVersion: 3, targetCount: 1, succeededCount: 1, unreachable: [], message: 'Applied settings v3 to 1 host.', tone: 'success' }); } @@ -472,6 +473,7 @@ beforeEach(() => { dashboard = DASHBOARD; trends = 'off'; attention = NOTHING_NEEDS_ATTENTION; + fleetHealth = FLEET_HEALTH; maintenance = maintenancePage(); legacyHostList = false; window.localStorage.removeItem('lb-host-columns'); @@ -1221,6 +1223,28 @@ describe('App', () => { expect(screen.getByText('600 s (10 minutes)', { exact: false })).toBeInTheDocument(); }); + it('notes how Xfce hosts count the screen delays when the fleet has them', async () => { + fleetHealth = { + ...FLEET_HEALTH, + Hosts: FLEET_HEALTH.Hosts.map((host, index) => (index === 0 ? { ...host, Desktop: 'xfce' } : host)), + }; + renderApp('/settings/hosts'); + + expect(await screen.findByText(/^Xfce hosts count these delays in whole minutes, up to 8 hours/)).toHaveTextContent( + 'On Xfce, a change reaches the sessions that start after it.', + ); + }); + + it('leaves the Xfce timing out of the note when the fleet runs only MATE', async () => { + fleetHealth = { + ...FLEET_HEALTH, + Hosts: FLEET_HEALTH.Hosts.map((host) => ({ ...host, Desktop: 'mate' })), + }; + renderApp('/settings/hosts'); + + expect(await screen.findByText(/^MATE hosts count these delays in whole minutes/)).not.toHaveTextContent('On Xfce'); + }); + it('lists sessions with what an operator needs to know', async () => { renderApp('/sessions'); diff --git a/front_end/web/src/lib/desktops.test.ts b/front_end/web/src/lib/desktops.test.ts new file mode 100644 index 0000000..6ed800b --- /dev/null +++ b/front_end/web/src/lib/desktops.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { minuteDesktops } from './desktops'; + +describe('desktops that count screen delays in minutes', () => { + it('names each one the hosts report, once and in a fixed order', () => { + expect( + minuteDesktops([{ Desktop: 'mate' }, { Desktop: 'gnome' }, { Desktop: 'xfce' }, { Desktop: 'mate' }]), + ).toEqual(['Xfce', 'MATE']); + }); + + it('is empty for GNOME, other desktops and hosts that have not reported', () => { + expect(minuteDesktops([{ Desktop: 'gnome' }, { Desktop: 'kde' }, { Desktop: null }])).toEqual([]); + expect(minuteDesktops([])).toEqual([]); + }); +}); diff --git a/front_end/web/src/lib/desktops.ts b/front_end/web/src/lib/desktops.ts new file mode 100644 index 0000000..ddfe460 --- /dev/null +++ b/front_end/web/src/lib/desktops.ts @@ -0,0 +1,13 @@ +import type { HostHealth } from '../types/broker'; + +/** The desktops that count screen blank and lock delays in whole minutes, by heartbeat value. */ +const MINUTE_DESKTOPS: ReadonlyArray = [ + ['xfce', 'Xfce'], + ['mate', 'MATE'], +]; + +/** The names of the desktops among these hosts that count screen delays in whole minutes. */ +export function minuteDesktops(hosts: ReadonlyArray>): string[] { + const reported = new Set(hosts.map((host) => host.Desktop)); + return MINUTE_DESKTOPS.filter(([desktop]) => reported.has(desktop)).map(([, name]) => name); +} diff --git a/front_end/web/src/pages/settings/HostSettings.tsx b/front_end/web/src/pages/settings/HostSettings.tsx index e1c5c4a..33aa57f 100644 --- a/front_end/web/src/pages/settings/HostSettings.tsx +++ b/front_end/web/src/pages/settings/HostSettings.tsx @@ -16,9 +16,16 @@ import { import { GlassCard } from '../../components/ui/GlassCard'; import { RelativeTime } from '../../components/ui/RelativeTime'; import { useToast } from '../../components/ui/Toast'; -import { useApplyHostSettings, useHostSettings, useHostSettingsHistory, useSaveHostSettings } from '../../hooks/useBroker'; +import { + useApplyHostSettings, + useFleetHealth, + useHostSettings, + useHostSettingsHistory, + useSaveHostSettings, +} from '../../hooks/useBroker'; import { useCan } from '../../hooks/useSession'; import { errorMessage } from '../../lib/api'; +import { minuteDesktops } from '../../lib/desktops'; import { valueOrDash } from '../../lib/format'; import { settingsHistoryChanges } from '../../lib/settingsDiff'; import type { HostSettings, Vm } from '../../types/broker'; @@ -107,6 +114,8 @@ export function HostSettingsPage() { const { data, isPending, error } = useHostSettings(); const saveSettings = useSaveHostSettings(); const applySettings = useApplyHostSettings(); + // Only for the note on desktops that count in minutes, so a failure just leaves it out. + const fleetHealth = useFleetHealth(); const can = useCan(); const [form, setForm] = useState(null); @@ -128,6 +137,7 @@ export function HostSettingsPage() { } const settings = data.settings; + const minuteDesktopNames = minuteDesktops(fleetHealth.data?.Hosts ?? []); function setValue(key: string, value: string | boolean) { setForm((current) => (current ? { ...current, [key]: value } : current)); @@ -277,9 +287,19 @@ export function HostSettingsPage() { {SCREEN_FIELDS.map(numberField)} + {minuteDesktopNames.length > 0 ? ( + + {minuteDesktopNames.join(' and ')} hosts count these delays in whole minutes, up to 8 + hours: the blank delay rounds up and the lock delay rounds to the nearest minute. + {minuteDesktopNames.includes('Xfce') + ? ' On Xfce, a change reaches the sessions that start after it.' + : null} + + ) : null} + setValue('ScreenLockSettingsLocked', checked)} /> diff --git a/linux_host/apply-host-settings.sh b/linux_host/apply-host-settings.sh index 941b087..0e58776 100644 --- a/linux_host/apply-host-settings.sh +++ b/linux_host/apply-host-settings.sh @@ -36,6 +36,14 @@ DCONF_LOCKS_DIRECTORY="$DCONF_LOCAL_DIRECTORY/locks" DCONF_SCREENSAVER_FILE="$DCONF_LOCAL_DIRECTORY/00-screensaver" DCONF_SCREENSAVER_LOCKS_FILE="$DCONF_LOCKS_DIRECTORY/screensaver" +# Xfce reads its settings from xfconf rather than dconf. +XFCE_CONFIG_DIRECTORY="/etc/xdg/xfce4" +XFCE_SCREENSAVER_FILE="$XFCE_CONFIG_DIRECTORY/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml" + +# MATE and xfce4-screensaver count their delays in whole minutes, and both screensavers treat +# more than eight hours as eight hours. +DESKTOP_DELAY_MAXIMUM_MINUTES=480 + RELEASE_TIMER_NAME="linuxbroker-release-session.timer" WATCHER_SERVICE_NAME="linuxbroker-release-session-watcher.service" RELEASE_TIMER_DROPIN_DIRECTORY="/etc/systemd/system/$RELEASE_TIMER_NAME.d" @@ -289,8 +297,26 @@ write_settings_file() { fi } +# A delay in seconds as the whole minutes MATE and xfce4-screensaver count in, rounded up or +# to the nearest minute, and at most what the screensavers accept. 0 stays 0. +delay_minutes() { + # Base 10, as a validated value can still carry a sign or leading zeros ("-0", "08"). + local seconds=$((10#${1#-})) rounding="$2" minutes + + if [ "$rounding" = "up" ]; then + minutes=$(( (seconds + 59) / 60 )) + else + minutes=$(( (seconds + 30) / 60 )) + fi + if [ "$minutes" -gt "$DESKTOP_DELAY_MAXIMUM_MINUTES" ]; then + minutes=$DESKTOP_DELAY_MAXIMUM_MINUTES + fi + printf '%s\n' "$minutes" +} + apply_dconf_settings() { local content locks_content dconf_changed=1 + local blank_minutes lock_minutes idle_activation=false if [ ! -d /etc/dconf ]; then log "dconf is not present on this host. Skipping screen lock policy." @@ -318,11 +344,16 @@ apply_dconf_settings() { mkdir -p "$DCONF_LOCAL_DIRECTORY" "$DCONF_LOCKS_DIRECTORY" + blank_minutes=$(delay_minutes "${SETTING_VALUES[ScreenIdleDelaySeconds]}" up) + lock_minutes=$(delay_minutes "${SETTING_VALUES[ScreenLockDelaySeconds]}" nearest) + [ "$blank_minutes" -gt 0 ] && idle_activation=true + content="# Managed by apply-host-settings.sh. Manual edits are overwritten."$'\n' content+="#"$'\n' - content+="# A locked GNOME greeter inside an xrdp/xpra session frequently cannot be unlocked"$'\n' - content+="# after a reconnect, which strands the host's lease. That is why the shipped defaults"$'\n' - content+="# disable the lock screen entirely rather than merely deferring it."$'\n' + content+="# A locked GNOME greeter inside an xrdp session frequently cannot be unlocked after a"$'\n' + content+="# reconnect, which strands the host's lease. That is why the shipped defaults disable"$'\n' + content+="# the lock screen entirely, on every desktop, rather than merely deferring it. GNOME"$'\n' + content+="# counts the delays below in seconds and MATE in minutes."$'\n' content+=$'\n' content+="[org/gnome/desktop/session]"$'\n' content+="idle-delay=uint32 ${SETTING_VALUES[ScreenIdleDelaySeconds]}"$'\n' @@ -334,6 +365,20 @@ apply_dconf_settings() { # Removes the lock screen entirely, including the Super+L shortcut and the Lock entry in # the system menu. Without this a user can still lock manually. content+="[org/gnome/desktop/lockdown]"$'\n' + content+="disable-lock-screen=${SETTING_VALUES[DisableLockScreen]}"$'\n' + content+=$'\n' + content+="[org/mate/desktop/session]"$'\n' + content+="idle-delay=$blank_minutes"$'\n' + content+=$'\n' + # An animated screensaver would keep sending screen updates to the client, so MATE only + # ever blanks the screen. + content+="[org/mate/screensaver]"$'\n' + content+="idle-activation-enabled=$idle_activation"$'\n' + content+="lock-enabled=${SETTING_VALUES[ScreenLockEnabled]}"$'\n' + content+="lock-delay=$lock_minutes"$'\n' + content+="mode='blank-only'"$'\n' + content+=$'\n' + content+="[org/mate/desktop/lockdown]"$'\n' content+="disable-lock-screen=${SETTING_VALUES[DisableLockScreen]}" if write_if_changed "$DCONF_SCREENSAVER_FILE" "$content" 644; then log "Updated screen lock policy in $DCONF_SCREENSAVER_FILE." @@ -346,7 +391,13 @@ apply_dconf_settings() { locks_content+="/org/gnome/desktop/session/idle-delay"$'\n' locks_content+="/org/gnome/desktop/screensaver/lock-enabled"$'\n' locks_content+="/org/gnome/desktop/screensaver/lock-delay"$'\n' - locks_content+="/org/gnome/desktop/lockdown/disable-lock-screen" + locks_content+="/org/gnome/desktop/lockdown/disable-lock-screen"$'\n' + locks_content+="/org/mate/desktop/session/idle-delay"$'\n' + locks_content+="/org/mate/screensaver/idle-activation-enabled"$'\n' + locks_content+="/org/mate/screensaver/lock-enabled"$'\n' + locks_content+="/org/mate/screensaver/lock-delay"$'\n' + locks_content+="/org/mate/screensaver/mode"$'\n' + locks_content+="/org/mate/desktop/lockdown/disable-lock-screen" if write_if_changed "$DCONF_SCREENSAVER_LOCKS_FILE" "$locks_content" 644; then log "Locked screen lock keys so users cannot override them." dconf_changed=0 @@ -371,6 +422,51 @@ apply_dconf_settings() { fi } +apply_xfconf_settings() { + local content blank_minutes lock_minutes saver_enabled=false lock_screen_enabled=true lock="" + + [ -d "$XFCE_CONFIG_DIRECTORY" ] || return 0 + + blank_minutes=$(delay_minutes "${SETTING_VALUES[ScreenIdleDelaySeconds]}" up) + lock_minutes=$(delay_minutes "${SETTING_VALUES[ScreenLockDelaySeconds]}" nearest) + [ "$blank_minutes" -gt 0 ] && saver_enabled=true + [ "${SETTING_VALUES[DisableLockScreen]}" = "true" ] && lock_screen_enabled=false + # A property in a system-wide channel file that only root may change is locked for every + # user: xfconf has no wildcard for everyone. + [ "${SETTING_VALUES[ScreenLockSettingsLocked]}" = "true" ] && lock=' unlocked="root"' + + content=''$'\n' + content+=''$'\n' + content+=''$'\n' + content+=$'\n' + content+=''$'\n' + content+=' '$'\n' + content+=" "$'\n' + content+=" "$'\n' + content+=' '$'\n' + content+=" "$'\n' + # xfce4-screensaver turns a delay under a minute into ten minutes, so none is given when + # blanking is off. + if [ "$saver_enabled" = "true" ]; then + content+=" "$'\n' + fi + content+=' '$'\n' + content+=' '$'\n' + content+=' '$'\n' + content+=" "$'\n' + content+=' '$'\n' + content+=" "$'\n' + content+=" "$'\n' + content+=' '$'\n' + content+=' '$'\n' + content+='' + + # xfconfd reads the file once per session, so a change reaches sessions started after it. + if write_if_changed "$XFCE_SCREENSAVER_FILE" "$content" 644; then + log "Updated Xfce screen lock policy in $XFCE_SCREENSAVER_FILE for sessions that start from now on." + fi +} + apply_systemd_settings() { local content units_changed=1 @@ -464,6 +560,7 @@ main() { write_settings_file apply_dconf_settings + apply_xfconf_settings apply_systemd_settings log "Applied settings version $SETTINGS_VERSION." diff --git a/linux_host/session_release_buffer/release-session.sh b/linux_host/session_release_buffer/release-session.sh index 85618e5..18f8727 100644 --- a/linux_host/session_release_buffer/release-session.sh +++ b/linux_host/session_release_buffer/release-session.sh @@ -14,6 +14,7 @@ LOCATION_PATH="/usr/local/bin" XORG_USERS_INFO_SCRIPT="$LOCATION_PATH/xrdp-who-xorg.sh" APPLY_SETTINGS_SCRIPT="$LOCATION_PATH/apply-host-settings.sh" SETTINGS_FILE="/etc/linuxbroker/host-settings.conf" +DESKTOP_FILE="/etc/linuxbroker/desktop.conf" STATE_DIRECTORY="/var/lib/linuxbroker-release-session" LEASE_DIRECTORY="$STATE_DIRECTORY/leases" CURRENT_USERS_DETAILS="$STATE_DIRECTORY/current_users.txt" @@ -770,18 +771,28 @@ collect_script_versions() { echo "$versions" } +# The desktop sessions start, which the host bootstrap records in desktop.conf, when it is +# installed; otherwise the first desktop found. The file is read, never sourced. detect_desktop() { - if command -v gnome-shell >/dev/null 2>&1; then - echo "gnome" - elif command -v xfce4-session >/dev/null 2>&1; then - echo "xfce" - elif command -v mate-session >/dev/null 2>&1; then - echo "mate" - elif command -v startplasma-x11 >/dev/null 2>&1; then - echo "kde" - else - echo "none" + local configured candidate + local -a candidates=(gnome xfce mate kde) + local -A commands=([gnome]=gnome-shell [xfce]=xfce4-session [mate]=mate-session [kde]=startplasma-x11) + + if [ -r "$DESKTOP_FILE" ]; then + configured=$(sed -n 's/^[[:space:]]*DESKTOP[[:space:]]*=//p' "$DESKTOP_FILE" 2>/dev/null | tail -n 1) + configured=$(printf '%s' "$configured" | tr -d "\"' \t\r" | tr '[:upper:]' '[:lower:]') + case "$configured" in + gnome|xfce|mate) candidates=("$configured" "${candidates[@]}") ;; + esac fi + + for candidate in "${candidates[@]}"; do + if command -v "${commands[$candidate]}" >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + echo "none" } detect_xrdp_version() { diff --git a/linux_host/tests/test_apply_host_settings.sh b/linux_host/tests/test_apply_host_settings.sh index 4bba362..b9683c7 100644 --- a/linux_host/tests/test_apply_host_settings.sh +++ b/linux_host/tests/test_apply_host_settings.sh @@ -11,10 +11,34 @@ setup_case() { install_basic_shims export FAKE_CALLS="$WORK_DIR/calls.log" : > "$FAKE_CALLS" - rm -rf /etc/linuxbroker /etc/dconf + rm -rf /etc/linuxbroker /etc/dconf /etc/xdg/xfce4 rm -f /var/log/linuxbroker-host-settings.log } +trap 'rm -rf /etc/xdg/xfce4' EXIT + +DCONF_KEYFILE="/etc/dconf/db/local.d/00-screensaver" +DCONF_LOCKS="/etc/dconf/db/local.d/locks/screensaver" +XFCONF_FILE="/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml" + +# The value of a key in one section of the dconf keyfile. +keyfile_value() { + awk -v section="[$1]" -v key="$2" ' + /^\[/ { in_section = ($0 == section); next } + in_section && index($0, key "=") == 1 { print substr($0, length(key) + 2) } + ' "$DCONF_KEYFILE" +} + +# The xfconf delays in the order they appear: the blank delay, when there is one, then the +# lock delay. +xfconf_delays() { + sed -n 's/.*/dev/null || fail "apply-host-settings.sh rejected $1" +} + setup_case printf '{"SettingsVersion":2,"PreserveSessionsOnDisconnect":true,"ScreenLockEnabled":false}\n' | bash "$SCRIPT" >/dev/null assert_file_contains "$SETTINGS_FILE" "LINUXBROKER_PRESERVE_SESSIONS_ON_DISCONNECT=true" @@ -34,4 +58,100 @@ setup_case printf '{"SettingsVersion":4,"PreserveSessionsOnDisconnect":true,"ScreenLockEnabled":true}\n' | bash "$SCRIPT" >/dev/null assert_file_contains "$SETTINGS_FILE" "LINUXBROKER_SCREEN_LOCK_ENABLED=true" assert_file_contains "$SETTINGS_FILE" "LINUXBROKER_PRESERVE_SESSIONS_ON_DISCONNECT=false" -assert_file_contains /var/log/linuxbroker-host-settings.log "PreserveSessionsOnDisconnect cannot be enabled" \ No newline at end of file +assert_file_contains /var/log/linuxbroker-host-settings.log "PreserveSessionsOnDisconnect cannot be enabled" + +# MATE gets the same policy as GNOME, in minutes. +setup_case +mkdir -p /etc/dconf +bash "$SCRIPT" --defaults >/dev/null || fail "--defaults failed" +assert_eq "$(keyfile_value org/gnome/desktop/session idle-delay)" "uint32 0" +assert_eq "$(keyfile_value org/mate/desktop/session idle-delay)" "0" +assert_eq "$(keyfile_value org/mate/screensaver idle-activation-enabled)" "false" +assert_eq "$(keyfile_value org/mate/screensaver lock-enabled)" "false" +assert_eq "$(keyfile_value org/mate/screensaver lock-delay)" "0" +assert_eq "$(keyfile_value org/mate/screensaver mode)" "'blank-only'" +assert_eq "$(keyfile_value org/mate/desktop/lockdown disable-lock-screen)" "true" +for key in /org/mate/desktop/session/idle-delay /org/mate/screensaver/idle-activation-enabled \ + /org/mate/screensaver/lock-enabled /org/mate/screensaver/lock-delay /org/mate/screensaver/mode \ + /org/mate/desktop/lockdown/disable-lock-screen /org/gnome/desktop/lockdown/disable-lock-screen; do + assert_eq "$(grep -cxF "$key" "$DCONF_LOCKS")" "1" "lock for $key" +done +assert_not_exists "$XFCONF_FILE" +assert_file_contains "$FAKE_CALLS" "dconf update" + +apply_settings '{"SettingsVersion":5,"ScreenIdleDelaySeconds":600,"ScreenLockEnabled":true,"ScreenLockDelaySeconds":90,"DisableLockScreen":false,"ScreenLockSettingsLocked":false}' +assert_eq "$(keyfile_value org/gnome/desktop/session idle-delay)" "uint32 600" +assert_eq "$(keyfile_value org/mate/desktop/session idle-delay)" "10" +assert_eq "$(keyfile_value org/mate/screensaver idle-activation-enabled)" "true" +assert_eq "$(keyfile_value org/mate/screensaver lock-enabled)" "true" +assert_eq "$(keyfile_value org/mate/screensaver lock-delay)" "2" +assert_eq "$(keyfile_value org/mate/desktop/lockdown disable-lock-screen)" "false" +assert_not_exists "$DCONF_LOCKS" + +# Xfce reads xfconf: a system-wide channel file, whose properties only root may change. +setup_case +mkdir -p /etc/xdg/xfce4 +bash "$SCRIPT" --defaults >/dev/null || fail "--defaults failed" +expected=' + + + + + + + + + + + + + + + + + + +' +assert_eq "$(cat "$XFCONF_FILE")" "$expected" +assert_eq "$(stat -c %a "$XFCONF_FILE")" "644" +assert_not_exists /etc/dconf + +apply_settings '{"SettingsVersion":6,"ScreenIdleDelaySeconds":600,"ScreenLockEnabled":true,"ScreenLockDelaySeconds":90,"DisableLockScreen":false,"ScreenLockSettingsLocked":false}' +expected=' + + + + + + + + + + + + + + + + + + + +' +assert_eq "$(cat "$XFCONF_FILE")" "$expected" + +# Unchanged settings leave the file alone. +: > /var/log/linuxbroker-host-settings.log +apply_settings '{"SettingsVersion":6,"ScreenIdleDelaySeconds":600,"ScreenLockEnabled":true,"ScreenLockDelaySeconds":90,"DisableLockScreen":false,"ScreenLockSettingsLocked":false}' +assert_not_contains_file /var/log/linuxbroker-host-settings.log "Xfce" + +# The blank delay rounds up, the lock delay to the nearest minute, and both stop at 8 hours. +setup_case +mkdir -p /etc/dconf /etc/xdg/xfce4 +for entry in "29|1|0" "30|1|1" "89|2|1" "90|2|2" "86400|480|480" '"090"|2|2'; do + IFS='|' read -r seconds blank lock <<< "$entry" + apply_settings "{\"SettingsVersion\":7,\"ScreenIdleDelaySeconds\":$seconds,\"ScreenLockDelaySeconds\":$seconds}" + assert_eq "$(keyfile_value org/mate/desktop/session idle-delay)" "$blank" "MATE blank delay for $seconds seconds" + assert_eq "$(keyfile_value org/mate/screensaver lock-delay)" "$lock" "MATE lock delay for $seconds seconds" + assert_eq "$(xfconf_delays)" "$blank $lock " "Xfce delays for $seconds seconds" +done \ No newline at end of file diff --git a/linux_host/tests/test_heartbeat.sh b/linux_host/tests/test_heartbeat.sh index 14130c1..7db6228 100644 --- a/linux_host/tests/test_heartbeat.sh +++ b/linux_host/tests/test_heartbeat.sh @@ -65,6 +65,7 @@ heartbeat_for_script() { hostname="testhost" SETTINGS_VERSION=7 RUN_MODE="systemd-timer" + DESKTOP_FILE="$WORK_DIR/desktop-$label.conf" mkdir -p "$STATE_DIRECTORY" "$bin" : > "$LOG_FILE" @@ -92,6 +93,22 @@ heartbeat_for_script() { assert_json "$payload" '.rootDiskFreePct >= 0 and .rootDiskFreePct <= 100 and .uptimeSeconds >= 0' "$label disk and uptime" assert_json "$payload" '.sessions | length == 1' "$label sessions" + # The desktop desktop.conf names is reported when it is installed. The file is never + # sourced. + printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/gnome-shell" + printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/xfce4-session" + chmod 755 "$SHIM_DIR/gnome-shell" "$SHIM_DIR/xfce4-session" + assert_eq "$(detect_desktop)" "gnome" "$label without desktop.conf" + printf '# Written by the bootstrap.\nDESKTOP="XFCE"\n' > "$DESKTOP_FILE" + assert_eq "$(detect_desktop)" "xfce" "$label desktop.conf" + printf 'DESKTOP=mate\n' > "$DESKTOP_FILE" + assert_eq "$(detect_desktop)" "gnome" "$label desktop.conf names a desktop that is not installed" + # shellcheck disable=SC2016 # the command must reach the file unexpanded + printf 'DESKTOP=$(touch %s/pwned)\n' "$WORK_DIR" > "$DESKTOP_FILE" + assert_eq "$(detect_desktop)" "gnome" "$label unusable desktop.conf" + assert_not_exists "$WORK_DIR/pwned" + rm -f "$SHIM_DIR/gnome-shell" "$SHIM_DIR/xfce4-session" "$DESKTOP_FILE" + # A wedged X server cannot stall the run: the idle lookup gives up after the probe timeout # and the session is reported without an idle time. printf '#!/bin/bash\nsleep 30\n' > "$SHIM_DIR/xprintidle" diff --git a/linux_host/tests/test_xrdp_startwm.sh b/linux_host/tests/test_xrdp_startwm.sh index 438510a..25cc546 100644 --- a/linux_host/tests/test_xrdp_startwm.sh +++ b/linux_host/tests/test_xrdp_startwm.sh @@ -14,7 +14,8 @@ FAKE_SESMAN_PID="" # Everything the tests create. Whatever was there before is set aside and put back. TOUCHED=(/etc/xrdp /usr/libexec/xrdp /etc/polkit-1 /etc/X11 /usr/share/gnome-session /etc/linuxbroker - "$LAUNCHER" "$SHIM_DIR/systemctl" "$SHIM_DIR/gnome-session" "$SHIM_DIR/logger") + "$LAUNCHER" "$SHIM_DIR/systemctl" "$SHIM_DIR/gnome-session" "$SHIM_DIR/startxfce4" "$SHIM_DIR/mate-session" + "$SHIM_DIR/logger") stop_fake_sesman() { if [ -n "$FAKE_SESMAN_PID" ]; then @@ -326,12 +327,17 @@ setup_debian_session() { mkdir -p /etc/X11/Xsession.d /usr/share/gnome-session/sessions /etc/linuxbroker "$WORK_DIR/home" fake_session_script /etc/xrdp/startwm.sh debian-startwm printf 'ORIGINAL_WM=/etc/xrdp/startwm.sh\n' > "$STATE_FILE" - printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/gnome-session" - chmod 755 "$SHIM_DIR/gnome-session" + install_desktop_shim gnome-session : > /usr/share/gnome-session/sessions/ubuntu.session printf 'LBTEST_PROFILE=sourced\nexport LBTEST_PROFILE\n' > "$WORK_DIR/home/.profile" } +# A stand-in for the command that starts a desktop, so the desktop counts as installed. +install_desktop_shim() { + printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/$1" + chmod 755 "$SHIM_DIR/$1" +} + # Starts a session the way xrdp-sesman does: as the user, in their home, with no arguments. run_session() { rm -f "$WORK_DIR/session.out" @@ -405,18 +411,97 @@ test_otherwise_the_distribution_script_runs() { assert_file_contains "$FAKE_CALLS" "gnome is not installed" } -test_rhel_runs_its_own_script() { +test_xfce_and_mate_on_debian() { setup_case + setup_debian_session + install_desktop_shim startxfce4 + install_desktop_shim mate-session + + printf 'DESKTOP=xfce\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "xsession" + assert_eq "$(session_value args)" "startxfce4" + assert_eq "$(session_value DESKTOP_SESSION)" "xfce" + assert_eq "$(session_value XDG_SESSION_DESKTOP)" "xfce" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "XFCE" + assert_eq "$(session_value GNOME_SHELL_SESSION_MODE)" "" + assert_eq "$(session_value XDG_SESSION_TYPE)" "x11" + assert_eq "$(session_value LBTEST_PROFILE)" "sourced" + assert_file_contains "$FAKE_CALLS" "logger -t linuxbroker-startwm -- Starting xfce" + + printf 'DESKTOP=MATE\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "xsession" + assert_eq "$(session_value args)" "mate-session" + assert_eq "$(session_value DESKTOP_SESSION)" "mate" + assert_eq "$(session_value XDG_SESSION_DESKTOP)" "mate" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "MATE" + assert_eq "$(session_value XDG_SESSION_TYPE)" "x11" + + # Another desktop installed alongside is not started in its place. + rm -f "$SHIM_DIR/mate-session" + run_session + assert_eq "$(session_value ran)" "debian-startwm" "MATE is not installed" + assert_eq "$(session_value DESKTOP_SESSION)" "" + assert_file_contains "$FAKE_CALLS" "mate is not installed" +} + +# A RHEL host: xorg-x11-xinit's Xsession and xrdp's startwm-bash.sh, recorded. +setup_rhel_session() { fake_session_script /etc/X11/xinit/Xsession rhel-xsession fake_session_script /usr/libexec/xrdp/startwm-bash.sh rhel-startwm mkdir -p /etc/linuxbroker "$WORK_DIR/home" printf 'ORIGINAL_WM=/usr/libexec/xrdp/startwm-bash.sh\n' > "$STATE_FILE" + install_desktop_shim gnome-session + printf 'LBTEST_PROFILE=sourced\nexport LBTEST_PROFILE\n' > "$WORK_DIR/home/.bash_profile" +} + +test_rhel_runs_its_own_script_for_gnome() { + setup_case + setup_rhel_session printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" - printf '#!/bin/sh\nexit 0\n' > "$SHIM_DIR/gnome-session" - chmod 755 "$SHIM_DIR/gnome-session" run_session assert_eq "$(session_value ran)" "rhel-startwm" + assert_eq "$(session_value DESKTOP_SESSION)" "" + assert_not_contains_file "$FAKE_CALLS" "is not installed" +} + +test_xfce_and_mate_on_rhel() { + setup_case + setup_rhel_session + install_desktop_shim startxfce4 + install_desktop_shim mate-session + + printf 'DESKTOP=xfce\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "rhel-xsession" + assert_eq "$(session_value args)" "startxfce4" + assert_eq "$(session_value DESKTOP_SESSION)" "xfce" + assert_eq "$(session_value XDG_SESSION_DESKTOP)" "xfce" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "XFCE" + assert_eq "$(session_value XDG_SESSION_TYPE)" "x11" + assert_eq "$(session_value LBTEST_PROFILE)" "sourced" "a login shell reads the profiles, as startwm-bash.sh does" + assert_file_contains "$FAKE_CALLS" "logger -t linuxbroker-startwm -- Starting xfce" + + printf 'DESKTOP=mate\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "rhel-xsession" + assert_eq "$(session_value args)" "mate-session" + assert_eq "$(session_value DESKTOP_SESSION)" "mate" + assert_eq "$(session_value XDG_CURRENT_DESKTOP)" "MATE" + + # Without the desktop, GNOME through the distribution's script. + rm -f "$SHIM_DIR/mate-session" + run_session + assert_eq "$(session_value ran)" "rhel-startwm" "MATE is not installed" + assert_file_contains "$FAKE_CALLS" "mate is not installed" + + # Without xinit's Xsession, too. + printf 'DESKTOP=xfce\n' > "$DESKTOP_FILE" + rm -f /etc/X11/xinit/Xsession + run_session + assert_eq "$(session_value ran)" "rhel-startwm" "no xinit Xsession" } test_an_unusable_record_falls_back() { @@ -448,7 +533,9 @@ test_install_reads_sesman_ini_as_xrdp_does test_install_refusals test_ubuntu_on_xorg test_otherwise_the_distribution_script_runs -test_rhel_runs_its_own_script +test_xfce_and_mate_on_debian +test_rhel_runs_its_own_script_for_gnome +test_xfce_and_mate_on_rhel test_an_unusable_record_falls_back echo "xrdp-startwm.sh tests passed" diff --git a/linux_host/xrdp-startwm.sh b/linux_host/xrdp-startwm.sh index 7a09330..220487a 100644 --- a/linux_host/xrdp-startwm.sh +++ b/linux_host/xrdp-startwm.sh @@ -332,23 +332,33 @@ configured_desktop() { esac } -# Starts the desktop through Debian's Xsession, after the same profiles xrdp's own script -# reads. Returns when the desktop is not installed. -start_debian_desktop() { - local startup +DESKTOP_STARTUP="" +# Exports what a display manager sets for the desktop and keeps the command that starts it in +# DESKTOP_STARTUP. Fails, changing nothing, when the desktop is not installed. +prepare_desktop() { case "$1" in gnome) command -v gnome-session >/dev/null 2>&1 || return 1 if [ -f "$UBUNTU_SESSION_FILE" ]; then # What GDM sets for "Ubuntu on Xorg": Ubuntu's session, theme and dock. export DESKTOP_SESSION=ubuntu XDG_SESSION_DESKTOP=ubuntu XDG_CURRENT_DESKTOP=ubuntu:GNOME GNOME_SHELL_SESSION_MODE=ubuntu - startup="gnome-session --session=ubuntu" + DESKTOP_STARTUP="gnome-session --session=ubuntu" else export DESKTOP_SESSION=gnome XDG_SESSION_DESKTOP=gnome XDG_CURRENT_DESKTOP=GNOME - startup="gnome-session" + DESKTOP_STARTUP="gnome-session" fi ;; + xfce) + command -v startxfce4 >/dev/null 2>&1 || return 1 + export DESKTOP_SESSION=xfce XDG_SESSION_DESKTOP=xfce XDG_CURRENT_DESKTOP=XFCE + DESKTOP_STARTUP="startxfce4" + ;; + mate) + command -v mate-session >/dev/null 2>&1 || return 1 + export DESKTOP_SESSION=mate XDG_SESSION_DESKTOP=mate XDG_CURRENT_DESKTOP=MATE + DESKTOP_STARTUP="mate-session" + ;; *) return 1 ;; @@ -356,12 +366,24 @@ start_debian_desktop() { # gnome-session starts the X11 flavor of its systemd units from this. export XDG_SESSION_TYPE=x11 - log_session "Starting $1 for $(id -un 2>/dev/null) with: $startup" + log_session "Starting $1 for $(id -un 2>/dev/null) with: $DESKTOP_STARTUP" +} + +# Starts the desktop through Debian's Xsession, after the same profiles xrdp's own script +# reads. +start_debian_desktop() { # shellcheck disable=SC2016 # expanded by the inner shell exec /bin/sh -c 'linuxbroker_startup=$1 if test -r /etc/profile; then . /etc/profile; fi if test -r "$HOME/.profile"; then . "$HOME/.profile"; fi -exec /etc/X11/Xsession "$linuxbroker_startup"' linuxbroker-startwm "$startup" +exec /etc/X11/Xsession "$linuxbroker_startup"' linuxbroker-startwm "$DESKTOP_STARTUP" +} + +# Starts the desktop through the xinit Xsession of RHEL and its rebuilds, from a login shell as +# xrdp's own startwm-bash.sh does, so the same profiles are read. +start_xinit_desktop() { + # shellcheck disable=SC2016 # expanded by the inner shell + exec /bin/bash -l -c 'exec /etc/X11/xinit/Xsession "$1"' linuxbroker-startwm "$DESKTOP_STARTUP" } run_original() { @@ -384,11 +406,19 @@ run_original() { } start_session() { - local desktop + local desktop starter="" desktop=$(configured_desktop) - if [ -n "$desktop" ] && [ -d /etc/X11/Xsession.d ] && [ -x /etc/X11/Xsession ]; then - start_debian_desktop "$desktop" + if [ -n "$desktop" ]; then + if [ -d /etc/X11/Xsession.d ] && [ -x /etc/X11/Xsession ]; then + starter=start_debian_desktop + elif [ "$desktop" != "gnome" ] && [ -x /etc/X11/xinit/Xsession ]; then + # GNOME is what the distribution's own script starts on RHEL. + starter=start_xinit_desktop + fi + fi + if [ -n "$starter" ]; then + prepare_desktop "$desktop" && "$starter" log_session "$desktop is not installed, so the distribution's session script is used." fi run_original From b09ee28fb0eec2457c0b3cb92877c767a0b0bc32 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 18:10:21 -0400 Subject: [PATCH 07/19] Remove xpra from the Linux hosts The broker only ever connected through xrdp. Connect-LinuxBroker.ps1 kept every -Mode other than desktop for starting an application through xpra, but that branch was an empty stub, while the bootstraps still added xpra.org's repository, installed xpra and opened TCP 443 for it. The RHEL 9 bootstrap no longer does any of that, and the host firewall allows only SSH and RDP. The RHEL 8 bootstrap is rebuilt from the RHEL 9 one and keeps only what differs on RHEL 8: the EPEL and Microsoft repository URLs, the CodeReady Builder repository it enables, and Atril, which MATE installs on its own because it needs CodeReady Builder. It now stops at the first step that fails, as RHEL 9 does, and drops the unused git owner detection and xrdp.ini path. Connect-LinuxBroker.ps1 opens the desktop whatever -Mode it gets and logs a warning for any value other than desktop, so a RemoteApp that passes one still works. The extension command is unchanged, so existing hosts do not run the bootstrap again. Migrate-LinuxHostReleaseAgent.ps1 removes xpra from them before it installs anything. It deletes the xpra repository definition first, because an unreachable xpra.org fails every dnf or yum command, then stops and disables the xpra services and sockets, removes xpra's own packages but not the libraries they pulled in, removes the xpra.org signing key and closes TCP 443 in firewalld or ufw. Every step is best effort and reported, and the migration carries on whatever happens. The previous Ubuntu bootstrap's xpra PPA never existed, so Ubuntu hosts only carry the archive's xpra package and the ufw rule. The README, DEPLOYMENT.md, the Bicep descriptions and an API comment no longer mention xpra, and the troubleshooting entry for the disabled xpra service on RHEL 9 is gone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +- api/config.py | 2 +- avd_host/broker/Connect-LinuxBroker.ps1 | 48 ++-- .../Configure-RHEL8-Host.sh | 205 +++++++----------- .../Configure-RHEL9-Host.sh | 86 +------- deploy/DEPLOYMENT.md | 9 +- deploy/Migrate-LinuxHostReleaseAgent.ps1 | 89 ++++++++ deploy/bicep/main.bicep | 2 +- deploy/bicep/main.json | 10 +- deploy/bicep/modules/Linux/main.bicep | 2 +- 10 files changed, 215 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 24892bc..09ba174 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Purpose -The **Linux Broker for AVD Access** is a solution designed to manage and broker user access to Linux hosts via Azure Virtual Desktop (AVD). It provides a scalable and efficient way to connect users to Linux virtual machines (VMs) using either Remote Desktop Protocol (RDP) for full desktop experiences or xpra (X Remote Application) for virtualized applications. +The **Linux Broker for AVD Access** is a solution designed to manage and broker user access to Linux hosts via Azure Virtual Desktop (AVD). It provides a scalable and efficient way to connect users to full desktops on Linux virtual machines (VMs) over the Remote Desktop Protocol (RDP), which the hosts serve with xrdp. This solution leverages Azure services such as managed identities, security groups, Azure App Service, Azure Functions, and Azure SQL Database to provide secure and efficient brokering, session management, and scaling of Linux hosts. @@ -15,7 +15,7 @@ The solution consists of the following components: - **Azure Virtual Desktop (AVD)**: Provides the interface for users to access Linux hosts. Users can connect via the AVD web client or any supported AVD client. -- **Broker Agent (`Connect-LinuxBroker.ps1`)**: A PowerShell script running on each AVD host that acts as an agent to broker connections to Linux hosts. It connects to the Broker API using managed identity to check out a Linux VM and initiate the appropriate connection (RDP or xpra). +- **Broker Agent (`Connect-LinuxBroker.ps1`)**: A PowerShell script running on each AVD host that acts as an agent to broker connections to Linux hosts. It connects to the Broker API using managed identity to check out a Linux VM and opens a Remote Desktop connection to it. - **Linux Hosts Cluster**: A set of Linux VMs that users connect to. Each Linux host has managed identity enabled and runs a Session Release Agent. @@ -67,8 +67,8 @@ The architecture ensures secure, efficient, and scalable management of Linux hos - The Broker Agent script (`Connect-LinuxBroker.ps1`) connects to the Broker API using the AVD host's managed identity. - It checks out an available Linux VM for the user. - The user's ID is added to the Linux host with a unique 25-character password. - - The user is added to appropriate user groups on the Linux host for RDP or xpra access. -4. **User Connects to Linux Host**: The user is connected to the Linux host via RDP or xpra and can work as needed. + - The user is added to appropriate user groups on the Linux host for RDP access. +4. **User Connects to Linux Host**: The user is connected to the Linux host via RDP and can work as needed. 5. **Session Management**: - If the user disconnects or logs off, the Session Release Agent on the Linux host reconciles the XRDP/Xorg session state immediately when possible and otherwise on the next safety-net poll. - A reconnect timer is initiated, 20 minutes by default and configurable from the portal. @@ -198,7 +198,7 @@ On RHEL, Xfce and MATE come from EPEL. These scripts: -- **Install XRDP and xpra**: Set up XRDP for full desktop access (RDP) and xpra for application virtualization, enabling users to connect via AVD. +- **Install xrdp**: Set up xrdp for full desktop access over RDP, enabling users to connect via AVD. The host firewall allows only SSH and RDP. - **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the desktop the deployment chose. - **Configure Authentication**: Sets up authentication mechanisms for secure user access. - **Deploy the Linux Session Release Agent**: Installs the timer-based reconciliation service plus a `systemd-logind` watcher that can trigger early reconciliations. The timer remains the fallback path so the system still converges even if event delivery is delayed or unavailable. diff --git a/api/config.py b/api/config.py index 4b2c748..3cdbce8 100644 --- a/api/config.py +++ b/api/config.py @@ -134,7 +134,7 @@ def env_int(name, default, minimum=None, maximum=None): } LINUX_HOST_SETTING_BOOLEANS = { - # Defaults disable the lock screen. A locked GNOME greeter inside an xrdp/xpra session + # Defaults disable the lock screen. A locked GNOME greeter inside an xrdp session # frequently cannot be unlocked after a reconnect, which strands the host's lease. # DisableLockScreen also removes the Super+L shortcut and the Lock menu entry, so a user # cannot lock manually either. diff --git a/avd_host/broker/Connect-LinuxBroker.ps1 b/avd_host/broker/Connect-LinuxBroker.ps1 index 1b5e81b..b191745 100644 --- a/avd_host/broker/Connect-LinuxBroker.ps1 +++ b/avd_host/broker/Connect-LinuxBroker.ps1 @@ -1,5 +1,5 @@ param ( - [Parameter(Mandatory = $false, HelpMessage = "Specify 'desktop' to use Remote Desktop, or provide the name of the application to run via xpra.")] + [Parameter(Mandatory = $false, HelpMessage = "Only 'desktop' is supported, which opens a Remote Desktop session to a Linux host. Any other value opens the desktop too.")] [string]$Mode = "desktop" ) @@ -91,6 +91,13 @@ function Get-AccessToken { } } +# Earlier releases kept every other value for starting a single application through xpra, +# which was never implemented and has been removed. A RemoteApp that still passes one gets the +# desktop rather than nothing. +if ($Mode -ine "desktop") { + Write-Log "Mode '$Mode' is not supported, so the desktop is opened instead." "WARNING" +} + # Define the API's Application ID URI (use the updated valid URL) $apiAppIdUri = "api://your_linuxbroker_api_client_id" # Replace with your API's actual Application ID URI @@ -174,34 +181,23 @@ if ($hasExistingCheckedInVM -and $checkoutResponse.IPAddress) { Write-Log "Failed to update credentials in Credential Manager: $_" "ERROR" } - if ($Mode -ieq "desktop") { - Write-Log "Connecting to $hostname (IP: $ipAddress) using Remote Desktop Connection..." "INFO" - try { - # xrdp presents a self-signed certificate, so skip the server authentication warning for this user. - $rdpClientKey = "HKCU:\Software\Microsoft\Terminal Server Client" - if (-not (Test-Path $rdpClientKey)) { - New-Item -Path $rdpClientKey -Force | Out-Null - } - New-ItemProperty -Path $rdpClientKey -Name "AuthenticationLevelOverride" -PropertyType DWord -Value 0 -Force | Out-Null + Write-Log "Connecting to $hostname (IP: $ipAddress) using Remote Desktop Connection..." "INFO" + try { + # xrdp presents a self-signed certificate, so skip the server authentication warning for this user. + $rdpClientKey = "HKCU:\Software\Microsoft\Terminal Server Client" + if (-not (Test-Path $rdpClientKey)) { + New-Item -Path $rdpClientKey -Force | Out-Null + } + New-ItemProperty -Path $rdpClientKey -Name "AuthenticationLevelOverride" -PropertyType DWord -Value 0 -Force | Out-Null - # Launch mstsc with the hostname or IP address - Start-Process mstsc.exe -ArgumentList "/v:$ipAddress" + # Launch mstsc with the hostname or IP address + Start-Process mstsc.exe -ArgumentList "/v:$ipAddress" - Write-Log "Successfully connected to $hostname (IP: $ipAddress) using Remote Desktop Connection." "INFO" - } - catch { - Write-Log "Failed to connect to $hostname (IP: $ipAddress) using Remote Desktop Connection: $_" "ERROR" - Show-UserMessage "Remote Desktop Connection could not be started for $hostname. Try again, or contact your administrator." "Error" - } + Write-Log "Successfully connected to $hostname (IP: $ipAddress) using Remote Desktop Connection." "INFO" } - else { - Write-Log "Running xpra command to launch application: $Mode" "INFO" - try { - # Add XPRA command - } - catch { - Write-Log "Failed to launch application '$Mode' using xpra: $_" "ERROR" - } + catch { + Write-Log "Failed to connect to $hostname (IP: $ipAddress) using Remote Desktop Connection: $_" "ERROR" + Show-UserMessage "Remote Desktop Connection could not be started for $hostname. Try again, or contact your administrator." "Error" } } else { diff --git a/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index 168c4cd..434dca6 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -5,47 +5,26 @@ LINUXBROKER_API_BASE_URL="${1:-}" LINUXBROKER_API_CLIENT_ID="${2:-}" -if [ -z "$LINUXBROKER_API_BASE_URL" ] || [ -z "$LINUXBROKER_API_CLIENT_ID" ]; then +if [[ -z "$LINUXBROKER_API_BASE_URL" || -z "$LINUXBROKER_API_CLIENT_ID" ]]; then echo "Linux Broker API base URL and client ID are required." exit 1 fi -case "$LINUXBROKER_API_BASE_URL" in - https://*) ;; - *) - echo "Linux Broker API base URL must start with https://" - exit 1 - ;; -esac +if [[ "$LINUXBROKER_API_BASE_URL" != https://* ]]; then + echo "Linux Broker API base URL must start with https://" + exit 1 +fi LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" # =============================== # Variables -# Default definition for the main project -GH_OWNER="microsoft" -GH_REPO="LinuxBrokerForAVDAccess" -GH_BRANCH="main" - -# if GIT repo, parse out the config data -remote_url=$(git config --get remote.origin.url 2>/dev/null) -branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - -# if current repo is a different fork/branch, change it accordingly -if [[ "$remote_url" =~ github.com[/:]([^/]+)/([^/.]+) ]]; then - GH_OWNER="${BASH_REMATCH[1]}" - GH_REPO="${BASH_REMATCH[2]}" - GH_BRANCH="$branch" -fi - epel_url="https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm" -xpra_repo_path="/etc/yum.repos.d/xpra.repo" -xpra_url="https://raw.githubusercontent.com/Xpra-org/xpra/master/packaging/repos/almalinux/xpra.repo" microsoft_packages_url="https://packages.microsoft.com/config/rhel/8/packages-microsoft-prod.rpm" # Override for sovereign or air-gapped clouds where raw.githubusercontent.com is unreachable. -script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/$GH_OWNER/$GH_REPO/refs/heads/$GH_BRANCH}" +script_source_root="${LINUXBROKER_SCRIPT_SOURCE_ROOT:-https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/main}" script_source_root="${script_source_root%/}" release_session_url="$script_source_root/linux_host/session_release_buffer/release-session.sh" @@ -63,13 +42,11 @@ patch_host_script_url="$script_source_root/linux_host/patch-host.sh" patch_host_script="/usr/local/bin/patch-host.sh" xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" -xrdp_ini="/etc/xrdp/xrdp.ini" arch=$( /bin/arch ) -remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" # Disable the screen saver and screen lock on this host. Enabled by default because a -# locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which +# locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') @@ -122,6 +99,8 @@ YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" # =============================== # Execution +set -e # Exit immediately if a command exits with a non-zero status + if [ -n "$orgId" ] && [ -n "$activationKey" ]; then echo "Registering the system..." sudo subscription-manager register --org="$orgId" --activationkey="$activationKey" @@ -133,9 +112,13 @@ fi echo "Updating and upgrading system packages..." sudo dnf update -y && sudo dnf upgrade -y +echo "Installing EPEL repository..." sudo dnf install -y "$epel_url" + +echo "Installing Microsoft repository..." sudo dnf install -y "$microsoft_packages_url" -sudo wget -O "$xpra_repo_path" "$xpra_url" + +echo "Installing essential packages..." sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf # Idle session enforcement degrades gracefully without xprintidle, so a host that cannot @@ -145,6 +128,7 @@ sudo dnf install -y xprintidle || echo "xprintidle is unavailable. Idle session case "$desktop" in gnome) + echo "Installing 'Server with GUI' group..." sudo dnf groupinstall -y "Server with GUI" ;; xfce) @@ -152,45 +136,22 @@ case "$desktop" in # xfce4-screensaver is the screen saver the host settings configure, and GNOME Keyring # keeps passwords for applications as it does on the other desktops. echo "Installing the Xfce desktop..." - if ! sudo dnf install -y --exclude=gdm @base-x @xfce-desktop xfce4-screensaver xfce4-notifyd \ - gnome-keyring gnome-keyring-pam; then - echo "ERROR: Could not install the Xfce desktop." - exit 1 - fi + sudo dnf install -y --exclude=gdm @base-x @xfce-desktop xfce4-screensaver xfce4-notifyd \ + gnome-keyring gnome-keyring-pam ;; mate) echo "Installing the MATE desktop..." - if ! sudo dnf install -y @base-x mate-session-manager mate-panel marco caja mate-settings-daemon \ + sudo dnf install -y @base-x mate-session-manager mate-panel marco caja mate-settings-daemon \ mate-control-center mate-terminal mate-screensaver mate-notification-daemon mate-polkit \ mate-power-manager mate-desktop mate-menus mate-themes mate-icon-theme mate-backgrounds \ - mate-media pluma eom engrampa; then - echo "ERROR: Could not install the MATE desktop." - exit 1 - fi + mate-media pluma eom engrampa # Atril, the document viewer, needs a package from CodeReady Builder on RHEL 8. sudo dnf install -y atril || echo "Atril is unavailable without CodeReady Builder, so MATE has no document viewer on this host." ;; esac -case "$remoteAccessTool" in - "xrdp") - remoteAccessPackages=("xrdp") - ;; - "xpra") - remoteAccessPackages=("xpra") - ;; - "both") - remoteAccessPackages=("xrdp" "xpra") - ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 - ;; -esac - -for pkg in "${remoteAccessPackages[@]}"; do - sudo dnf install -y "$pkg" -done +echo "Installing xrdp..." +sudo dnf install -y xrdp echo "Setting default target to graphical..." sudo systemctl set-default graphical.target @@ -205,33 +166,20 @@ else sudo systemctl enable --now firewalld fi -echo "Configuring firewall to allow $remoteAccessTool connections..." +echo "Configuring firewall to allow SSH and xrdp connections..." sudo firewall-cmd --permanent --add-port=22/tcp # Always allow SSH +sudo firewall-cmd --permanent --add-port=3389/tcp +sudo firewall-cmd --permanent --add-service=ms-wbt || echo "Service 'ms-wbt' may not be available. Skipping." -if [ "$remoteAccessTool" = "xrdp" ] || [ "$remoteAccessTool" = "both" ]; then - sudo firewall-cmd --permanent --add-port=3389/tcp - sudo firewall-cmd --permanent --add-service=ms-wbt - sudo firewall-cmd --permanent --add-port=443/tcp - if systemctl is-active --quiet xrdp; then - echo "xrdp service is already active." - else - echo "Starting and enabling xrdp service..." - sudo systemctl start xrdp - sudo systemctl enable xrdp --now - fi -fi - -if [ "$remoteAccessTool" = "xpra" ] || [ "$remoteAccessTool" = "both" ]; then - sudo firewall-cmd --permanent --add-port=443/tcp - if systemctl is-active --quiet xpra; then - echo "xpra service is already active." - else - echo "Starting and enabling xpra service..." - sudo systemctl start xpra - sudo systemctl enable xpra --now - fi +if systemctl is-active --quiet xrdp; then + echo "xrdp service is already active." +else + echo "Starting and enabling xrdp service..." + sudo systemctl start xrdp + sudo systemctl enable xrdp --now fi +echo "Reloading firewall configurations..." sudo firewall-cmd --reload echo "Firewall configuration completed." @@ -253,11 +201,54 @@ sudo wget -O "$output_directory/xrdp-who-xorg.sh" "$xrdp_who_xorg_url" echo "Downloading logind-session-watcher.sh..." sudo wget -O "$WATCHER_SCRIPT_PATH" "$logind_watcher_url" -sudo chmod +x "$SCRIPT_PATH" +echo "Downloading create-user.sh..." +sudo wget -O "$create_user_script" "$create_user_script_url" + +echo "Downloading manage-lease.sh..." +sudo wget -O "$manage_lease_script" "$manage_lease_script_url" + +echo "Downloading apply-host-settings.sh..." +sudo wget -O "$apply_settings_script" "$apply_settings_script_url" + +echo "Downloading session-control.sh..." +sudo wget -O "$session_control_script" "$session_control_script_url" + +echo "Downloading patch-host.sh..." +sudo wget -O "$patch_host_script" "$patch_host_script_url" + +echo "Downloading xrdp-startwm.sh..." +sudo wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" + +echo "Setting execute permissions for downloaded scripts..." +sudo chmod +x "$SCRIPT_PATH" sudo chmod +x "$output_directory/xrdp-who-xorg.sh" sudo chmod +x "$WATCHER_SCRIPT_PATH" +sudo chmod +x "$create_user_script" +sudo chmod +x "$manage_lease_script" +sudo chmod +x "$apply_settings_script" +sudo chmod +x "$session_control_script" +sudo chmod +x "$patch_host_script" +sudo chmod +x "$xrdp_startwm_script" echo "Downloaded scripts are now executable." +# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. For +# GNOME that is the distribution's own session script, as before. +echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." +sudo mkdir -p "$(dirname "$desktop_file")" +sudo chmod 755 "$(dirname "$desktop_file")" +cat </dev/null +# Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every +# xrdp session. +DESKTOP=$desktop +EOF +sudo chmod 644 "$desktop_file" + +if ! sudo "$xrdp_startwm_script" --install; then + echo "ERROR: Could not configure xrdp to start sessions through $xrdp_startwm_script." + exit 1 +fi + +echo "Creating log and user details files..." sudo mkdir -p "$state_directory" sudo touch "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" sudo chown root:root "$LOG_FILE" "$CURRENT_USERS_DETAILS" "$PREVIOUS_USERS_FILE" "$DISCONNECTED_USERS_FILE" @@ -346,52 +337,10 @@ sudo systemctl enable --now "$WATCHER_SERVICE_NAME" sudo systemctl start "$SYSTEMD_SERVICE_NAME" echo "Systemd timer and logind watcher configured successfully." -# Copy Unique User creation script before generating sudoers rules -echo "Downloading create-user.sh..." -sudo wget -O "$create_user_script" "$create_user_script_url" -sudo chmod +x "$create_user_script" - -echo "Downloading manage-lease.sh..." -sudo wget -O "$manage_lease_script" "$manage_lease_script_url" - -echo "Downloading apply-host-settings.sh..." -sudo wget -O "$apply_settings_script" "$apply_settings_script_url" - -echo "Downloading session-control.sh..." -sudo wget -O "$session_control_script" "$session_control_script_url" - -echo "Downloading patch-host.sh..." -sudo wget -O "$patch_host_script" "$patch_host_script_url" -sudo chmod +x "$manage_lease_script" -sudo chmod +x "$apply_settings_script" -sudo chmod +x "$session_control_script" -sudo chmod +x "$patch_host_script" - -echo "Downloading xrdp-startwm.sh..." -sudo wget -O "$xrdp_startwm_script" "$xrdp_startwm_script_url" -sudo chmod +x "$xrdp_startwm_script" - -# xrdp starts every session through xrdp-startwm.sh, which starts the desktop named here. For -# GNOME that is the distribution's own session script, as before. -echo "Configuring xrdp to start sessions through xrdp-startwm.sh..." -sudo mkdir -p "$(dirname "$desktop_file")" -sudo chmod 755 "$(dirname "$desktop_file")" -cat </dev/null -# Written by the Linux Broker host bootstrap: the desktop xrdp-startwm.sh starts in every -# xrdp session. -DESKTOP=$desktop -EOF -sudo chmod 644 "$desktop_file" - -if ! sudo "$xrdp_startwm_script" --install; then - echo "ERROR: Could not configure xrdp to start sessions through $xrdp_startwm_script." - exit 1 -fi - -# Create AVD user and give limited sudo rights if ! id avdadmin >/dev/null 2>&1; then sudo useradd avdadmin fi + # Only the commands the broker API actually invokes with sudo. Privileged file work # (mount, chown, chmod, lease markers, host settings) happens inside the allowlisted # scripts, each of which validates its own input. @@ -407,7 +356,6 @@ else echo "ERROR: Generated sudoers policy failed validation." exit 1 fi -# Note: public ssh key is still needed for avdadmin echo "avdadmin user is created and permissioned" # Seed the Linux Broker host settings profile. This writes the screen lock policy for each @@ -428,5 +376,4 @@ if ! printf '%s' "$settings_seed" | sudo "$apply_settings_script"; then exit 1 fi -# Complete echo "System configuration complete." diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index 96431c1..c86625a 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -21,8 +21,6 @@ LINUXBROKER_API_BASE_URL="${LINUXBROKER_API_BASE_URL%/}" # Variables epel_url="https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm" -xpra_repo_path="/etc/yum.repos.d/xpra.repo" -xpra_url="https://raw.githubusercontent.com/Xpra-org/xpra/master/packaging/repos/almalinux/xpra.repo" microsoft_packages_url="https://packages.microsoft.com/config/rhel/9/packages-microsoft-prod.rpm" # Override for sovereign or air-gapped clouds where raw.githubusercontent.com is unreachable. @@ -46,10 +44,9 @@ xrdp_startwm_script_url="$script_source_root/linux_host/xrdp-startwm.sh" xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" arch=$( /bin/arch ) -remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" # Disable the screen saver and screen lock on this host. Enabled by default because a -# locked greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which +# locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') @@ -121,14 +118,6 @@ sudo dnf install -y "$epel_url" echo "Installing Microsoft repository..." sudo dnf install -y "$microsoft_packages_url" -echo "Adding Xpra repository..." -# curl ships with the base image, while wget is only installed in the next step. xpra is -# optional, so a missing repository definition must not stop the host provisioning on xrdp. -if ! sudo curl -fsSL -o "$xpra_repo_path" "$xpra_url"; then - sudo rm -f "$xpra_repo_path" - echo "WARNING: Unable to download the Xpra repository definition from $xpra_url." -fi - echo "Installing essential packages..." sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf @@ -159,34 +148,8 @@ case "$desktop" in ;; esac -case "$remoteAccessTool" in - "xrdp"|"xpra"|"both") - ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 - ;; -esac - -if [[ "$remoteAccessTool" == "xrdp" || "$remoteAccessTool" == "both" ]]; then - echo "Installing xrdp..." - sudo dnf install -y xrdp -fi - -# xpra comes from a third-party repository whose dependencies can drift from the RHEL minor -# release. When both tools are requested, an xpra failure leaves the host serving xrdp only. -if [[ "$remoteAccessTool" == "xpra" || "$remoteAccessTool" == "both" ]]; then - echo "Installing xpra..." - if ! sudo dnf install -y xpra; then - if [[ "$remoteAccessTool" == "both" ]]; then - echo "WARNING: xpra could not be installed. Continuing with xrdp only." - remoteAccessTool="xrdp" - else - echo "ERROR: xpra could not be installed." - exit 1 - fi - fi -fi +echo "Installing xrdp..." +sudo dnf install -y xrdp echo "Setting default target to graphical..." sudo systemctl set-default graphical.target @@ -201,42 +164,17 @@ else sudo systemctl enable --now firewalld fi -echo "Configuring firewall to allow $remoteAccessTool connections..." +echo "Configuring firewall to allow SSH and xrdp connections..." sudo firewall-cmd --permanent --add-port=22/tcp # Always allow SSH +sudo firewall-cmd --permanent --add-port=3389/tcp +sudo firewall-cmd --permanent --add-service=ms-wbt || echo "Service 'ms-wbt' may not be available. Skipping." -if [[ "$remoteAccessTool" == "xrdp" || "$remoteAccessTool" == "both" ]]; then - sudo firewall-cmd --permanent --add-port=3389/tcp - sudo firewall-cmd --permanent --add-port=443/tcp - sudo firewall-cmd --permanent --add-service=ms-wbt || echo "Service 'ms-wbt' may not be available. Skipping." - if systemctl is-active --quiet xrdp; then - echo "xrdp service is already active." - else - echo "Starting and enabling xrdp service..." - sudo systemctl start xrdp - sudo systemctl enable xrdp --now - fi -fi - -if [[ "$remoteAccessTool" == "xpra" || "$remoteAccessTool" == "both" ]]; then - sudo firewall-cmd --permanent --add-port=443/tcp - if systemctl is-active --quiet xpra; then - echo "xpra service is already active." - elif [[ "$remoteAccessTool" == "both" ]]; then - echo "Starting and enabling xpra service..." - sudo systemctl enable xpra --now || true - # systemctl returns as soon as the proxy process forks, so a proxy that exits during - # startup only shows up a few seconds later. Left alone, the failed unit marks the host - # degraded and xpra.socket keeps accepting connections for a proxy that cannot run. - sleep 15 - if ! systemctl is-active --quiet xpra; then - echo "WARNING: The xpra service did not stay running. Disabling it. xrdp remains available." - sudo systemctl disable --now xpra.socket xpra.service || true - sudo systemctl reset-failed xpra.service || true - fi - else - echo "Starting and enabling xpra service..." - sudo systemctl enable xpra --now - fi +if systemctl is-active --quiet xrdp; then + echo "xrdp service is already active." +else + echo "Starting and enabling xrdp service..." + sudo systemctl start xrdp + sudo systemctl enable xrdp --now fi echo "Reloading firewall configurations..." diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 6367b22..c2c2064 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -241,7 +241,7 @@ If you prefer to be prompted locally, leave both values unset and run `azd up` f Linux hosts run GNOME unless `linuxHostDesktop` chooses Xfce or MATE. By default the bootstrap script disables the screen saver and screen lock on those hosts, whichever desktop they run. -This is on by default because a locked GNOME greeter inside an xrdp or xpra session frequently +This is on by default because a locked GNOME greeter inside an xrdp session frequently cannot be unlocked after a reconnect. When that happens the user cannot get back into the desktop, and the host stays leased until the lease is released manually. Xfce and MATE hosts get the same default, so the posture does not depend on the desktop a deployment chose. @@ -595,6 +595,7 @@ This release changes which Linux distributions and desktops the deployment offer ``` - **Hosts can run Xfce or MATE.** The new `linuxHostDesktop` parameter chooses the desktop: `gnome`, the default and the only desktop until now, `xfce` or `mate`. The bootstrap installs it, from EPEL on RHEL and from Ubuntu's own packages on Ubuntu, and records it in `/etc/linuxbroker/desktop.conf`. `xrdp-startwm.sh` starts the desktop named there, and the heartbeat reports it in **Fleet health**. The host settings apply to all three desktops; see [Linux Host Screen Lock](#linux-host-screen-lock) for how MATE and Xfce count the screen delays in minutes and when Xfce sessions pick up a change. With `gnome` the extension command is unchanged, so an environment that keeps the default sees no change to its hosts. Changing the value changes the extension command, so the next `azd provision` runs the bootstrap again on existing hosts. It adds the new desktop next to the old one, and the sessions that start afterwards use the new desktop. Drain the hosts first, because the bootstrap also updates every package and reinstalls the release agent, and on Ubuntu it restarts xrdp. To choose Xfce or MATE when you run a bootstrap script by hand, set `LINUXBROKER_DESKTOP=xfce` or `LINUXBROKER_DESKTOP=mate` in its environment. +- **xpra is removed.** The broker only ever connected through xrdp, and `Connect-LinuxBroker.ps1` never started an xpra application, so the bootstrap no longer adds the xpra repository, installs xpra or opens TCP 443; the host firewall allows only SSH and RDP. The RHEL 8 bootstrap is now built from the RHEL 9 one, so it also stops at the first step that fails, as the RHEL 9 bootstrap does. The extension command is unchanged, so `azd provision` does not run the bootstrap again on existing hosts. Instead, [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release removes xpra from them: it stops and disables the xpra services and sockets, deletes `/etc/yum.repos.d/xpra.repo` and the xpra.org signing key, removes xpra's own packages but not the libraries they brought in, and closes TCP 443 in firewalld or ufw. Each step is best effort and reported in the migration output, and a host where one fails is still migrated. If a host serves something else on TCP 443, open it again after the migration. `Connect-LinuxBroker.ps1` now opens the desktop whatever `-Mode` it is given, and logs a warning for any value other than `desktop`. ## Manual Steps After `azd up` @@ -826,13 +827,9 @@ If the share is reachable but `df -h ~` inside a session shows the local disk, c Current hosts keep the home mounted while the host holds the user's lease, which lasts from checkout until the broker returns the host. At return, `manage-lease.sh` unmounts the home before the broker runs `userdel -r`, so only the empty local mount point is removed and the profile stays on the share. The API also refuses to run `userdel -r` while the home is still mounted, and logs `home directory is still mounted` instead. If a checkout fails after the host has written the lease, the API runs the same cleanup before it puts the host back in the pool. -### `xpra.service` is disabled on a RHEL 9 host - -The system proxy service installed by the upstream xpra 6.5 packages exits during startup on RHEL 9. Its unit binds a QUIC socket, and the `aioquic` module it needs is not packaged for RHEL 9. Left enabled, the failed unit would mark the host as degraded, so the bootstrap disables `xpra.socket` and `xpra.service` and logs a warning. xrdp, which the **Linux Desktop** app uses, is not affected. - ### A RHEL session is stuck on a lock screen that will not accept the password -The GNOME lock screen inside an xrdp or xpra session often cannot be unlocked after a reconnect. Confirm the screen lock configuration actually applied on the host using the commands in [Linux Host Screen Lock](#linux-host-screen-lock). The most common cause is a missing `system-db:local` line in `/etc/dconf/profile/user`, which makes GNOME ignore the settings even though the files under `/etc/dconf/db/local.d/` are present. +The GNOME lock screen inside an xrdp session often cannot be unlocked after a reconnect. Confirm the screen lock configuration actually applied on the host using the commands in [Linux Host Screen Lock](#linux-host-screen-lock). The most common cause is a missing `system-db:local` line in `/etc/dconf/profile/user`, which makes GNOME ignore the settings even though the files under `/etc/dconf/db/local.d/` are present. ### A session starts a different desktop than `linuxHostDesktop` names diff --git a/deploy/Migrate-LinuxHostReleaseAgent.ps1 b/deploy/Migrate-LinuxHostReleaseAgent.ps1 index 3ef7286..bc74be6 100644 --- a/deploy/Migrate-LinuxHostReleaseAgent.ps1 +++ b/deploy/Migrate-LinuxHostReleaseAgent.ps1 @@ -275,6 +275,95 @@ download_file() { return 1 } +# Earlier bootstraps installed xpra next to xrdp and opened TCP 443 for it, but the broker only +# ever connects through xrdp. Every step is best effort, so a host where one fails still gets the +# new agent. The repository definition goes first, because while xpra.org is unreachable it makes +# every dnf or yum command on the host fail. +remove_xpra() { + local repo_file='/etc/yum.repos.d/xpra.repo' + local unit packages rules output key + local remove_status=0 + + if [ -f "$repo_file" ]; then + if rm -f "$repo_file"; then + echo "Removed the xpra repository definition $repo_file." + else + echo "WARNING: Unable to remove $repo_file." + fi + fi + + if command -v systemctl >/dev/null 2>&1; then + for unit in xpra.socket xpra-encoder.socket xpra.service xpra-encoder.service; do + systemctl disable --now "$unit" >/dev/null 2>&1 || true + systemctl reset-failed "$unit" >/dev/null 2>&1 || true + done + fi + + packages='' + if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + packages=$(rpm -qa --qf '%{NAME}\n' 2>/dev/null | grep -E '^(python[0-9]*-)?xpra(-|$)' | sort -u | paste -sd ' ' - || true) + elif command -v dpkg-query >/dev/null 2>&1; then + packages=$(dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null \ + | awk 'substr($1, 2, 1) != "n" && $2 ~ /^(python3-)?xpra(-|$)/ { print $2 }' | sort -u | paste -sd ' ' - || true) + fi + + if [ -n "$packages" ]; then + # Package names contain no spaces or glob characters, so the list is split unquoted. + # Only xpra's own packages are removed. The libraries they pulled in stay, because a + # user's own tools may rely on them without any installed package requiring them. + # Run Command returns only the end of the output, so the transaction log is kept back + # unless the removal fails. + # shellcheck disable=SC2086 + if command -v dnf >/dev/null 2>&1; then + output=$(dnf remove -y --noautoremove $packages 2>&1) || remove_status=$? + elif command -v yum >/dev/null 2>&1; then + output=$(yum remove -y $packages 2>&1) || remove_status=$? + else + output=$(DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 purge -y $packages 2>&1) || remove_status=$? + fi + if [ "$remove_status" -eq 0 ]; then + echo "Removed the xpra packages: $packages." + else + echo "WARNING: Unable to remove the xpra packages ($packages); the package manager exited with $remove_status:" + printf '%s\n' "$output" | tail -n 5 + fi + fi + + # dnf imported xpra.org's signing key when it first installed xpra. Nothing needs it once the + # repository is gone, and leaving it would keep trusting any package xpra.org signs. + if command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + for key in $(rpm -q gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE} %{SUMMARY}\n' 2>/dev/null | awk '/xpra\.org/ { print $1 }' || true); do + if rpm -e "$key" >/dev/null 2>&1; then + echo "Removed the xpra.org package signing key $key." + else + echo "WARNING: Unable to remove the xpra.org package signing key $key." + fi + done + fi + + if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then + if firewall-cmd --permanent --query-port=443/tcp >/dev/null 2>&1; then + if firewall-cmd --permanent --remove-port=443/tcp >/dev/null && firewall-cmd --reload >/dev/null; then + echo 'Closed TCP 443 in firewalld.' + else + echo 'WARNING: Unable to close TCP 443 in firewalld.' + fi + fi + elif command -v ufw >/dev/null 2>&1; then + rules=$(ufw show added 2>/dev/null || true) + if grep -qx 'ufw allow 443/tcp' <<< "$rules"; then + if ufw delete allow 443/tcp >/dev/null; then + echo 'Closed TCP 443 in ufw.' + else + echo 'WARNING: Unable to close TCP 443 in ufw.' + fi + fi + fi +} + +# Called in an || list so that set -e cannot stop the migration partway through the cleanup. +remove_xpra || echo 'WARNING: The xpra cleanup did not finish.' + ensure_command curl curl ensure_command jq jq ensure_command dconf dconf || ensure_command dconf dconf-cli || echo 'dconf is unavailable; screen lock policy will be written but not compiled.' diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 702ba33..f679481 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -150,7 +150,7 @@ param linuxHostSshPublicKey string = '' @description('Linux host OS image SKU.') param linuxHostOsVersion string = '9-LVM' -@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') +@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param linuxHostDisableScreenLock bool = true @allowed([ diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index a83e2db..e805437 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "7494438435876162804" + "templateHash": "3082428001236918502" } }, "parameters": { @@ -326,7 +326,7 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." } }, "linuxHostDesktop": { @@ -565,7 +565,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "1459326504640237068" + "templateHash": "10982394411437139989" } }, "parameters": { @@ -2869,7 +2869,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "8451551061526823506" + "templateHash": "4319518226710739497" } }, "parameters": { @@ -2943,7 +2943,7 @@ "type": "bool", "defaultValue": true, "metadata": { - "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." + "description": "Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control." } }, "desktop": { diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index 0730192..3130283 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -33,7 +33,7 @@ param OSVersion string @description('Root URL the host bootstrap scripts are downloaded from. Point this at a reachable mirror for sovereign or air-gapped clouds.') param scriptSourceRoot string = 'https://raw.githubusercontent.com/microsoft/LinuxBrokerForAVDAccess/refs/heads/main' -@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp/xpra session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') +@description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') param disableScreenLock bool = true @allowed([ From 724bc5c29a58dc83d578fc086c4c7ea49e18f77c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 18:53:45 -0400 Subject: [PATCH 08/19] Offer Rocky Linux 9 and AlmaLinux 9 as Linux host images linuxHostOsVersion accepts rocky-9 (resf rockylinux-x86_64 9-base) and alma-9 (almalinux almalinux-x86_64 9-gen2). Both images are Generation 2 and support Trusted Launch. Their images have 10 GB and 30 GB disks, so these hosts get the 64 GB OS disk that RHEL hosts have, and cloud-init grows the root partition at first boot. Both run Configure-RHEL9-Host.sh, which reads the distribution from os-release. On Rocky and Alma it skips subscription registration, enables CRB and installs EPEL from the distribution's own epel-release package. It also installs firewalld, which their Azure images leave out. Rocky's image is a free Marketplace offer with a purchase plan. The VM sets the plan only for rocky-9, and preprovision accepts its terms in the deployment subscription when needed. The subscription must also be allowed to buy Marketplace offers. DEPLOYMENT.md explains the MarketplacePurchaseEligibilityFailed error and recommends alma-9 where those purchases are blocked. Existing values set no plan and no disk size. A what-if against a live environment shows the same NoChange for its RHEL 9 hosts and their extensions with the old and new templates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- .../Configure-RHEL9-Host.sh | 35 +++++++++++-- deploy/DEPLOYMENT.md | 21 ++++++-- deploy/Initialize-DeploymentEnvironment.ps1 | 40 +++++++++++++++ deploy/bicep/main.bicep | 4 +- deploy/bicep/main.json | 49 +++++++++++++++++-- deploy/bicep/main.resources.bicep | 2 + deploy/bicep/modules/Linux/main.bicep | 41 ++++++++++++++++ 8 files changed, 180 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 09ba174..882f7ee 100644 --- a/README.md +++ b/README.md @@ -186,15 +186,16 @@ The custom script extension for the AVD host: The custom script extensions support the following Linux distributions: - **Red Hat Enterprise Linux (RHEL) 8 and 9** +- **Rocky Linux 9 and AlmaLinux 9**: rebuilds of RHEL 9 that need no Red Hat subscription, set up by the RHEL 9 script - **Ubuntu 24.04**: Canonical's server image, with a desktop added Each deployment chooses the desktop its hosts run with `linuxHostDesktop`: -- **GNOME**, the default: the `Server with GUI` group on RHEL, and on Ubuntu the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg +- **GNOME**, the default: the `Server with GUI` group on RHEL and its rebuilds, and on Ubuntu the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg - **Xfce** - **MATE** -On RHEL, Xfce and MATE come from EPEL. +On RHEL, Rocky Linux and AlmaLinux, Xfce and MATE come from EPEL. Rocky Linux and AlmaLinux install EPEL from their own repositories. These scripts: diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index c86625a..530bce3 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -1,6 +1,7 @@ #!/bin/bash -# Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 9 +# Installs and configures the necessary packages for Linux Broker for AVD Access on RHEL 9 and +# on its rebuilds, Rocky Linux 9 and AlmaLinux 9 LINUXBROKER_API_BASE_URL="${1:-}" LINUXBROKER_API_CLIENT_ID="${2:-}" @@ -45,6 +46,18 @@ xrdp_startwm_script="/usr/local/bin/xrdp-startwm.sh" arch=$( /bin/arch ) +# Rocky Linux and AlmaLinux have no subscription to register. They ship the EPEL release +# package in their extras repository, and EPEL needs their CRB repository, which is disabled. +os_id="" +if [ -r /etc/os-release ]; then + os_id=$(. /etc/os-release && echo "${ID:-}") +fi + +case "$os_id" in + rocky|almalinux) rebuild="true" ;; + *) rebuild="false" ;; +esac + # Disable the screen saver and screen lock on this host. Enabled by default because a # locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which # strands the host's lease. Set LINUXBROKER_DISABLE_SCREEN_LOCK=false to keep the lock screen. @@ -101,7 +114,9 @@ YOUR_LINUXBROKER_API_BASE_URL="$LINUXBROKER_API_BASE_URL" set -e # Exit immediately if a command exits with a non-zero status -if [ -n "$orgId" ] && [ -n "$activationKey" ]; then +if [ "$rebuild" = "true" ]; then + echo "Skipping system registration, which only RHEL needs." +elif [ -n "$orgId" ] && [ -n "$activationKey" ]; then echo "Registering the system..." sudo subscription-manager register --org="$orgId" --activationkey="$activationKey" sudo subscription-manager repos --enable "codeready-builder-for-rhel-9-${arch}-rpms" --enable "rhel-9-for-x86_64-appstream-rpms" --enable "rhel-9-for-x86_64-baseos-rpms" @@ -112,14 +127,24 @@ fi echo "Updating and upgrading system packages..." sudo dnf update -y && sudo dnf upgrade -y -echo "Installing EPEL repository..." -sudo dnf install -y "$epel_url" +if [ "$rebuild" = "true" ]; then + echo "Enabling the CRB repository..." + sudo dnf install -y dnf-plugins-core + sudo dnf config-manager --set-enabled crb + + echo "Installing EPEL repository..." + sudo dnf install -y epel-release +else + echo "Installing EPEL repository..." + sudo dnf install -y "$epel_url" +fi echo "Installing Microsoft repository..." sudo dnf install -y "$microsoft_packages_url" +# The Azure images of Rocky Linux and AlmaLinux leave out firewalld, which RHEL's includes. echo "Installing essential packages..." -sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf +sudo dnf install -y wget util-linux azure-cli xorgxrdp nfs-utils curl jq dconf firewalld # Idle session enforcement degrades gracefully without xprintidle, so a host that cannot # install it must still finish provisioning rather than fail the extension. diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index c2c2064..5f452f3 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -100,8 +100,8 @@ The checked-in [bicep/main.parameters.example.json](bicep/main.parameters.exampl - `avdSessionHostCount`: number of AVD hosts to provision. - `linuxHostVmSize`: Linux host VM size. - `avdVmSize`: AVD host VM size. -- `linuxHostOsVersion`: Linux image SKU. Defaults to `9-LVM` (RHEL 9). The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. `24_04-lts` deploys Canonical's Ubuntu 24.04 server image and adds the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg. -- `linuxHostDesktop`: `gnome`, `xfce` or `mate`. The desktop the Linux hosts run in xrdp sessions. Defaults to `gnome`, which is the `Server with GUI` group on RHEL and the Ubuntu desktop on Ubuntu. Xfce and MATE come from EPEL on RHEL and from Ubuntu's own packages on Ubuntu. Changing it on existing hosts runs their bootstrap again at the next `azd provision`, so drain them first. See [Upgrading To Distribution And Desktop Support](#upgrading-to-distribution-and-desktop-support). +- `linuxHostOsVersion`: Linux image SKU. Defaults to `9-LVM` (RHEL 9). The RHEL options (`8-LVM`, `9-LVM`) map to the Generation 2 images that Trusted Launch requires. `rocky-9` and `alma-9` deploy Rocky Linux 9 and AlmaLinux 9, rebuilds of RHEL 9 that need no Red Hat subscription, and run the RHEL 9 bootstrap, with CRB and EPEL from the distribution's own repositories; their hosts get the 64 GB OS disk that RHEL hosts have. Rocky Linux 9 is a free Azure Marketplace image with a purchase plan: `preprovision` accepts its terms in the deployment subscription, and the subscription must be allowed to buy Marketplace images. Where it is not, use `alma-9`, whose image has no plan; see [A Rocky Linux host deployment failed with `MarketplacePurchaseEligibilityFailed`](#a-rocky-linux-host-deployment-failed-with-marketplacepurchaseeligibilityfailed). `24_04-lts` deploys Canonical's Ubuntu 24.04 server image and adds the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg. +- `linuxHostDesktop`: `gnome`, `xfce` or `mate`. The desktop the Linux hosts run in xrdp sessions. Defaults to `gnome`, which is the `Server with GUI` group on RHEL, Rocky Linux and AlmaLinux, and the Ubuntu desktop on Ubuntu. Xfce and MATE come from EPEL on RHEL, Rocky Linux and AlmaLinux, and from Ubuntu's own packages on Ubuntu. Changing it on existing hosts runs their bootstrap again at the next `azd provision`, so drain them first. See [Upgrading To Distribution And Desktop Support](#upgrading-to-distribution-and-desktop-support). - `linuxHostDisableScreenLock`: `true` or `false`. Disables the screen saver and screen lock on the Linux hosts, whichever desktop they run. Defaults to `true`. See [Linux Host Screen Lock](#linux-host-screen-lock). - `azureCloudName`: `AzurePublic`, `AzureUSGovernment`, or `AzureCustom`. See [Choosing The Target Azure Cloud](#choosing-the-target-azure-cloud). - `scriptSourceRoot`: root URL the Linux host and AVD host bootstrap scripts are downloaded from. @@ -371,6 +371,7 @@ It currently does all of the following: - When AVD hosts are deployed, enables Microsoft Entra authentication for RDP on the Windows Cloud Login service principal if it is not already enabled. The host pool turns on Entra single sign-on, which depends on this tenant-wide setting. `preprovision` never disables it. - Creates or reuses frontend and API client secrets. - Generates or reuses Linux host SSH keys. +- When Linux hosts are deployed with `linuxHostOsVersion=rocky-9`, accepts the Azure Marketplace terms of the Rocky Linux 9 image in the deployment subscription unless they are accepted already. - Writes resolved values back into the azd environment in both uppercase and camelCase forms expected by the deployment. The API app registration is also configured with the Graph application permissions the API uses to validate host and group membership. @@ -389,7 +390,7 @@ Important deployment characteristics: - Linux host auth defaults to `SSH`. - Linux hosts register their names in the `linuxbroker.internal` private DNS zone unless `domainName` is set, and the API's `DOMAIN_NAME` setting points at whichever suffix is in effect. - The API's `NFS_SHARE` setting points at the provisioned Azure Files share unless `nfsShare` is set. The storage account disables public network access and shared key access, and it allows non-HTTPS traffic because NFS does not use HTTPS; the private endpoint is the only path to it. -- RHEL hosts use Generation 2 images so they can run with Trusted Launch. +- RHEL, Rocky Linux and AlmaLinux hosts use Generation 2 images so they can run with Trusted Launch. - The AVD host pool prefers RemoteApp and sets RDP properties that enable Microsoft Entra single sign-on to the Microsoft Entra joined session hosts. - Linux hosts run the desktop that `linuxHostDesktop` names, GNOME by default, with the screen saver and screen lock disabled unless `linuxHostDisableScreenLock` is `false`. See [Linux Host Screen Lock](#linux-host-screen-lock). - Key Vault stores `db-password` and `linux-host`. @@ -596,6 +597,7 @@ This release changes which Linux distributions and desktops the deployment offer - **Hosts can run Xfce or MATE.** The new `linuxHostDesktop` parameter chooses the desktop: `gnome`, the default and the only desktop until now, `xfce` or `mate`. The bootstrap installs it, from EPEL on RHEL and from Ubuntu's own packages on Ubuntu, and records it in `/etc/linuxbroker/desktop.conf`. `xrdp-startwm.sh` starts the desktop named there, and the heartbeat reports it in **Fleet health**. The host settings apply to all three desktops; see [Linux Host Screen Lock](#linux-host-screen-lock) for how MATE and Xfce count the screen delays in minutes and when Xfce sessions pick up a change. With `gnome` the extension command is unchanged, so an environment that keeps the default sees no change to its hosts. Changing the value changes the extension command, so the next `azd provision` runs the bootstrap again on existing hosts. It adds the new desktop next to the old one, and the sessions that start afterwards use the new desktop. Drain the hosts first, because the bootstrap also updates every package and reinstalls the release agent, and on Ubuntu it restarts xrdp. To choose Xfce or MATE when you run a bootstrap script by hand, set `LINUXBROKER_DESKTOP=xfce` or `LINUXBROKER_DESKTOP=mate` in its environment. - **xpra is removed.** The broker only ever connected through xrdp, and `Connect-LinuxBroker.ps1` never started an xpra application, so the bootstrap no longer adds the xpra repository, installs xpra or opens TCP 443; the host firewall allows only SSH and RDP. The RHEL 8 bootstrap is now built from the RHEL 9 one, so it also stops at the first step that fails, as the RHEL 9 bootstrap does. The extension command is unchanged, so `azd provision` does not run the bootstrap again on existing hosts. Instead, [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release removes xpra from them: it stops and disables the xpra services and sockets, deletes `/etc/yum.repos.d/xpra.repo` and the xpra.org signing key, removes xpra's own packages but not the libraries they brought in, and closes TCP 443 in firewalld or ufw. Each step is best effort and reported in the migration output, and a host where one fails is still migrated. If a host serves something else on TCP 443, open it again after the migration. `Connect-LinuxBroker.ps1` now opens the desktop whatever `-Mode` it is given, and logs a warning for any value other than `desktop`. +- **Rocky Linux 9 and AlmaLinux 9 hosts.** `linuxHostOsVersion` accepts `rocky-9` and `alma-9`. Both run the RHEL 9 bootstrap, which skips the subscription registration on them, enables their CRB repository, installs EPEL from their own `epel-release` package and adds firewalld, which their Azure images leave out. The existing values set no purchase plan or disk size, so the template leaves existing hosts as they are. A VM's image cannot be changed in place, so to move an environment to one of them, replace its hosts as described for RHEL 7 above. Rocky Linux 9 is an Azure Marketplace image with a purchase plan, so the subscription must be allowed to buy Marketplace images; see [A Rocky Linux host deployment failed with `MarketplacePurchaseEligibilityFailed`](#a-rocky-linux-host-deployment-failed-with-marketplacepurchaseeligibilityfailed). ## Manual Steps After `azd up` @@ -806,7 +808,18 @@ az functionapp start --name --resource-group ### The Linux host deployment failed with a Trusted Launch error -Trusted Launch requires Generation 2 images. The RHEL options map to Gen2 SKUs; if you customized the image, choose a Gen2 SKU. +Trusted Launch requires Generation 2 images. The RHEL, Rocky Linux and AlmaLinux options map to Gen2 images; if you customized the image, choose a Gen2 SKU. + +### A Rocky Linux host deployment failed with `MarketplacePurchaseEligibilityFailed` + +The Rocky Linux 9 image is a free Azure Marketplace offer from the Rocky Enterprise Software Foundation, and Azure checks that the subscription may buy it before it creates the VM. `preprovision` accepts the image's terms, so when the check still fails, the subscription cannot buy Marketplace offers at all: its billing account turns off Azure Marketplace purchases, its offer type does not allow them, or a private Azure Marketplace does not list the offer. Confirm the terms with `az vm image terms show --urn resf:rockylinux-x86_64:9-base:latest --query accepted`, then either have the billing account's administrator allow the purchase, or switch to AlmaLinux 9, whose image has no purchase plan: + +```powershell +azd env set linuxHostOsVersion alma-9 +azd provision +``` + +If the failed deployment left a Linux host VM behind, delete it before you run `azd provision` again, because Azure cannot change the image of an existing VM. ### A VM deployment failed with `SkuNotAvailable` diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index ca10c6b..3d35ef7 100644 --- a/deploy/Initialize-DeploymentEnvironment.ps1 +++ b/deploy/Initialize-DeploymentEnvironment.ps1 @@ -452,6 +452,36 @@ function Ensure-LinuxHostSshKeys { } } +# Linux host images sold through Azure Marketplace with a purchase plan, by linuxHostOsVersion. +# Azure creates a VM from one only after the subscription accepts its terms. +$linuxHostMarketplaceImages = @{ + 'rocky-9' = 'resf:rockylinux-x86_64:9-base:latest' +} + +function Ensure-LinuxHostImageTerms { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$OsVersion, + [Parameter(Mandatory = $true)][string]$SubscriptionId + ) + + $urn = $linuxHostMarketplaceImages[$OsVersion] + if (-not $urn) { + return + } + + $terms = az vm image terms show --urn $urn --subscription $SubscriptionId --output json 2>$null | ConvertFrom-Json + if ($LASTEXITCODE -eq 0 -and $terms.accepted) { + Write-Host "The Azure Marketplace terms of '$urn' are already accepted in subscription '$SubscriptionId'." + return + } + + Write-Host "Accepting the Azure Marketplace terms of '$urn' in subscription '$SubscriptionId', which linuxHostOsVersion '$OsVersion' needs." + az vm image terms accept --urn $urn --subscription $SubscriptionId --output none + if ($LASTEXITCODE -ne 0) { + throw "Failed to accept the Azure Marketplace terms of '$urn' in subscription '$SubscriptionId'. Accept them with 'az vm image terms accept --urn $urn --subscription $SubscriptionId', or set linuxHostOsVersion to alma-9, which has no Marketplace terms." + } +} + function Ensure-DefaultEnvValue { param( [Parameter(Mandatory = $true)][string]$Key, @@ -1101,6 +1131,16 @@ if ($deployLinuxHostsValue -eq 'true') { [void](Ensure-LinuxHostSshKeys) } +if ($deployLinuxHostsValue -eq 'true' -and (ConvertTo-IntParameterValue -Key 'linuxHostCount') -gt 0) { + # The Linux hosts deploy to the subscription azd provisions, not to vmSubscriptionId. + $linuxHostSubscriptionId = Get-FirstNonEmptyValue -Values @( + (Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID'), + $env:AZURE_SUBSCRIPTION_ID, + $subscription.id + ) + Ensure-LinuxHostImageTerms -OsVersion (Get-AzdEnvValue -Key 'linuxHostOsVersion') -SubscriptionId $linuxHostSubscriptionId +} + $apiApp = Ensure-ApiApplication -CloudContext $cloudContext -DisplayName $apiAppDisplayName $apiServicePrincipal = Ensure-ServicePrincipal -AppId $apiApp.appId Ensure-ClientSecret -Application $apiApp -EnvClientIdKey 'API_CLIENT_ID' -EnvSecretKey 'API_CLIENT_SECRET' | Out-Null diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index f679481..8e50eee 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -145,9 +145,11 @@ param linuxHostSshPublicKey string = '' @allowed([ '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '24_04-lts' ]) -@description('Linux host OS image SKU.') +@description('Linux host image: 8-LVM (RHEL 8), 9-LVM (RHEL 9), rocky-9 (Rocky Linux 9), alma-9 (AlmaLinux 9) or 24_04-lts (Ubuntu 24.04). Rocky Linux 9 is a Marketplace image: the subscription must accept its terms once and be allowed to buy Marketplace images, even though it costs nothing.') param linuxHostOsVersion string = '9-LVM' @description('Disable the screen saver and screen lock on the Linux hosts, whichever desktop they run. Enabled by default because a locked GNOME greeter inside an xrdp session often cannot be unlocked after a reconnect, which strands the host lease. Set to false to keep the lock screen, for example to satisfy a STIG or CIS idle-lock control.') diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index e805437..27c3af5 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "3082428001236918502" + "templateHash": "3452646503869980587" } }, "parameters": { @@ -316,10 +316,12 @@ "allowedValues": [ "8-LVM", "9-LVM", + "rocky-9", + "alma-9", "24_04-lts" ], "metadata": { - "description": "Linux host OS image SKU." + "description": "Linux host image: 8-LVM (RHEL 8), 9-LVM (RHEL 9), rocky-9 (Rocky Linux 9), alma-9 (AlmaLinux 9) or 24_04-lts (Ubuntu 24.04). Rocky Linux 9 is a Marketplace image: the subscription must accept its terms once and be allowed to buy Marketplace images, even though it costs nothing." } }, "linuxHostDisableScreenLock": { @@ -565,7 +567,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "10982394411437139989" + "templateHash": "3838469552702841404" } }, "parameters": { @@ -774,6 +776,8 @@ "allowedValues": [ "8-LVM", "9-LVM", + "rocky-9", + "alma-9", "24_04-lts" ] }, @@ -2869,7 +2873,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "4319518226710739497" + "templateHash": "11700213154129506841" } }, "parameters": { @@ -2929,6 +2933,8 @@ "allowedValues": [ "8-LVM", "9-LVM", + "rocky-9", + "alma-9", "24_04-lts" ] }, @@ -2998,6 +3004,37 @@ "cmd": "[format('{0} bash Configure-RHEL9-Host.sh {1}', variables('bootstrapEnv'), variables('bootstrapArgs'))]" } }, + "rocky-9": { + "image": { + "publisher": "resf", + "offer": "rockylinux-x86_64", + "sku": "9-base", + "version": "latest" + }, + "plan": { + "name": "9-base", + "product": "rockylinux-x86_64", + "publisher": "resf" + }, + "osDiskSizeGB": 64, + "script": { + "uri": "[format('{0}/custom_script_extensions/Configure-RHEL9-Host.sh', variables('normalizedScriptSourceRoot'))]", + "cmd": "[format('{0} bash Configure-RHEL9-Host.sh {1}', variables('bootstrapEnv'), variables('bootstrapArgs'))]" + } + }, + "alma-9": { + "image": { + "publisher": "almalinux", + "offer": "almalinux-x86_64", + "sku": "9-gen2", + "version": "latest" + }, + "osDiskSizeGB": 64, + "script": { + "uri": "[format('{0}/custom_script_extensions/Configure-RHEL9-Host.sh', variables('normalizedScriptSourceRoot'))]", + "cmd": "[format('{0} bash Configure-RHEL9-Host.sh {1}', variables('bootstrapEnv'), variables('bootstrapArgs'))]" + } + }, "24_04-lts": { "image": { "publisher": "canonical", @@ -3048,6 +3085,7 @@ "name": "[variables('vmNames')[copyIndex()]]", "location": "[parameters('location')]", "tags": "[parameters('tags')]", + "plan": "[tryGet(variables('selectedConfig'), 'plan')]", "identity": { "type": "SystemAssigned" }, @@ -3066,7 +3104,8 @@ "storageProfile": { "imageReference": "[variables('selectedConfig').image]", "osDisk": { - "createOption": "FromImage" + "createOption": "FromImage", + "diskSizeGB": "[tryGet(variables('selectedConfig'), 'osDiskSizeGB')]" } }, "securityProfile": { diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 4cbe016..699dabe 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -83,6 +83,8 @@ param linuxHostSshPublicKey string = '' @allowed([ '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '24_04-lts' ]) param linuxHostOsVersion string = '9-LVM' diff --git a/deploy/bicep/modules/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index 3130283..ce54c42 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -26,6 +26,8 @@ param sshPublicKey string = '' @allowed([ '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '24_04-lts' ]) param OSVersion string @@ -73,6 +75,12 @@ var linuxConfiguration = authType == 'SSH' // The VMs below use Trusted Launch, which requires Generation 2 images. The RHEL SKUs named // by OSVersion (8-LVM, 9-LVM) are Generation 1, so each maps to its Gen2 equivalent. +// Rocky Linux and AlmaLinux, rebuilds of RHEL, run the RHEL 9 bootstrap. Their images have +// 10 GB and 30 GB disks, so their hosts get the 64 GB OS disk of RHEL hosts, and cloud-init +// grows the root partition at first boot. Rocky's is a Marketplace image with a purchase plan: +// it costs nothing, but the subscription must accept its terms and be allowed to buy +// Marketplace images. The other images set no plan or size, which keeps existing hosts as +// they are. var imageConfigs = { '8-LVM': { image: { @@ -98,6 +106,37 @@ var imageConfigs = { cmd: '${bootstrapEnv} bash Configure-RHEL9-Host.sh ${bootstrapArgs}' } } + 'rocky-9': { + image: { + publisher: 'resf' + offer: 'rockylinux-x86_64' + sku: '9-base' + version: 'latest' + } + plan: { + name: '9-base' + product: 'rockylinux-x86_64' + publisher: 'resf' + } + osDiskSizeGB: 64 + script: { + uri: '${normalizedScriptSourceRoot}/custom_script_extensions/Configure-RHEL9-Host.sh' + cmd: '${bootstrapEnv} bash Configure-RHEL9-Host.sh ${bootstrapArgs}' + } + } + 'alma-9': { + image: { + publisher: 'almalinux' + offer: 'almalinux-x86_64' + sku: '9-gen2' + version: 'latest' + } + osDiskSizeGB: 64 + script: { + uri: '${normalizedScriptSourceRoot}/custom_script_extensions/Configure-RHEL9-Host.sh' + cmd: '${bootstrapEnv} bash Configure-RHEL9-Host.sh ${bootstrapArgs}' + } + } '24_04-lts': { image: { publisher: 'canonical' @@ -154,6 +193,7 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ name: name location: location tags: tags + plan: selectedConfig.?plan identity: { type: 'SystemAssigned' } @@ -177,6 +217,7 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ imageReference: selectedConfig.image osDisk: { createOption: 'FromImage' + diskSizeGB: selectedConfig.?osDiskSizeGB } } securityProfile: { From ec02be33631a9b2d045fe91cdff07119504c322b Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 20:47:20 -0400 Subject: [PATCH 09/19] Give Ubuntu MATE hosts a working panel Live validation on Ubuntu 24.04 with the MATE desktop showed an empty, unusable top panel on first login. ubuntu-mate-default-settings points the panel at the "familiar" layout, which expects Ubuntu MATE's own applets and indicators that the broker's slim desktop install leaves out. The Ubuntu CSE now sets org.mate.panel general default-layout to the stock "default" layout in the host's dconf keyfile when the desktop is MATE, so new users get the menu bar, window list, clock, notification area and workspace switcher. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Configure-Ubuntu24_desktop-Host.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index 6d8631c..c8ba03a 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -193,6 +193,17 @@ hide-reboot-notification=true notify-ubuntu-advantage-available=false show-livepatch-status-icon=false EOF +# mate-session-manager brings ubuntu-mate-default-settings, which makes the Ubuntu MATE panel +# layout the default. That layout needs the Brisk menu, indicator and trash applets, which the +# core MATE set leaves out, so every new user was asked to delete three broken applets. MATE's +# own layout uses only the applets mate-panel ships. +if [ "$desktop" = "mate" ]; then + cat >> "$ubuntu_dconf_file" <<'EOF' + +[org/mate/panel/general] +default-layout='default' +EOF +fi chmod 644 "$ubuntu_dconf_file" dconf update From 28cc2c27621e53a89f9dddfc2df24d5935eb3095 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 20:47:20 -0400 Subject: [PATCH 10/19] Skip the GNOME welcome tour on RHEL-family hosts Live validation on RHEL 9 and AlmaLinux 9 with GNOME showed GNOME Shell's "Welcome to " tour dialog on each new user's first login, in front of the desktop the user asked for. The RHEL 9 family CSE now marks the dialog as already shown in a local dconf keyfile when the desktop is GNOME, so the first login starts without prompts, as it already does on Ubuntu hosts. RHEL 8's Getting Started window is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- custom_script_extensions/Configure-RHEL9-Host.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/custom_script_extensions/Configure-RHEL9-Host.sh b/custom_script_extensions/Configure-RHEL9-Host.sh index 530bce3..1fd3423 100644 --- a/custom_script_extensions/Configure-RHEL9-Host.sh +++ b/custom_script_extensions/Configure-RHEL9-Host.sh @@ -92,6 +92,7 @@ activationKey="${RHEL_ACTIVATION_KEY:-}" output_directory="/usr/local/bin" state_directory="/var/lib/linuxbroker-release-session" desktop_file="/etc/linuxbroker/desktop.conf" +gnome_dconf_file="/etc/dconf/db/local.d/10-linuxbroker-gnome" SCRIPT_PATH="$output_directory/release-session.sh" WATCHER_SCRIPT_PATH="$output_directory/logind-session-watcher.sh" @@ -155,6 +156,16 @@ case "$desktop" in gnome) echo "Installing 'Server with GUI' group..." sudo dnf groupinstall -y "Server with GUI" + + # GNOME Shell asks every new user whether to take its tour. Marking the dialog as + # already shown keeps the first login free of prompts, as on Ubuntu hosts. The host + # settings step makes sure the dconf profile reads this local database. + echo "Turning off the GNOME welcome dialog for broker users..." + sudo mkdir -p "$(dirname "$gnome_dconf_file")" + printf '%s\n' '# Managed by the Linux Broker host bootstrap.' '[org/gnome/shell]' \ + "welcome-dialog-last-shown-version='4294967295'" | sudo tee "$gnome_dconf_file" >/dev/null + sudo chmod 644 "$gnome_dconf_file" + sudo dconf update ;; xfce) # GDM is left out, as it brings GNOME Shell with it and xrdp needs no display manager. From 6407568bba97560f874e27480b67350fb92eae6e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 21:03:34 -0400 Subject: [PATCH 11/19] Keep systemd-networkd in charge of Ubuntu desktop hosts' network Live validation lost an Ubuntu 24.04 GNOME host 35 minutes after it was deployed. ubuntu-desktop-minimal brings NetworkManager, whose netplan default in /usr/lib/netplan makes NetworkManager the renderer, so the next netplan generator run removed systemd-networkd's configuration for eth0 while networkd kept running with it. When Defender for Cloud's MDE extension later installed a package, needrestart restarted the services the bootstrap's upgrade had left on old libraries, systemd-networkd among them. networkd released the DHCP address on the way down, came back with no configuration for the NIC, and NetworkManager still treated it as unmanaged, so the host stayed off the network until it was rebooted. Any package install, or an update of systemd itself, would have done the same. The Ubuntu CSE now shadows that netplan default with a file of the same name in /etc/netplan that selects systemd-networkd, before the desktop packages are installed. The Azure image already marks its NICs as unmanaged for NetworkManager, and the Xfce and MATE hosts never had it, so every Ubuntu desktop host now keeps the image's network stack. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Configure-Ubuntu24_desktop-Host.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh index c8ba03a..eca0d07 100644 --- a/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh +++ b/custom_script_extensions/Configure-Ubuntu24_desktop-Host.sh @@ -152,6 +152,22 @@ if ! dpkg-query -W -f='${Status}' firefox 2>/dev/null | grep -q 'install ok inst desktop_packages+=(firefox-) fi +# ubuntu-desktop-minimal brings NetworkManager, whose netplan default in /usr/lib/netplan +# hands the NIC to NetworkManager the next time netplan generates its configuration. The +# running systemd-networkd keeps the address until it restarts, and a later restart, such as +# needrestart after any package install, then drops it and leaves the host off the network +# until it reboots. The Azure image keeps NetworkManager off its NICs, so a file of the same +# name in /etc/netplan keeps systemd-networkd in charge, whichever desktop is installed. +echo "Keeping systemd-networkd in charge of the network..." +cat > /etc/netplan/00-network-manager-all.yaml <<'EOF' +# Managed by the Linux Broker host bootstrap. Shadows the NetworkManager default in +# /usr/lib/netplan so that systemd-networkd keeps configuring the Azure NIC. +network: + version: 2 + renderer: networkd +EOF +chmod 600 /etc/netplan/00-network-manager-all.yaml + echo "Installing the desktop, xrdp and the Linux Broker dependencies..." apt_get -y install jq nfs-common dconf-cli curl wget ufw libnotify-bin x11-utils dbus-user-session \ xrdp xorgxrdp "${desktop_packages[@]}" From 1901801969cccfd05e39b3124560b7f1c5d14e43 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 21:42:26 -0400 Subject: [PATCH 12/19] Unlock the login keyring with a key the broker keeps The account password changes at every checkout and xrdp-sesman has no keyring PAM module, so a GNOME login keyring could never be unlocked: every application that stores a secret asked for a password the user never had. The broker now keeps a key per user and unlocks the keyring with it before the desktop starts. - Bicep adds a keyring vault (kr...), gives the API Key Vault Secrets Officer on that vault only, and sets KEYRING_VAULT_URL. - At checkout the API reads the secret keyring-, or creates it, and sends it to create-user.sh as a second stdin line. Scripts that predate it read only the first line. An applied profile reset writes a new version; the old versions still open the keyring kept with the old profile. Without the setting no key is sent, and a Key Vault error never fails a checkout: that worker sends no key for five minutes, and a soft-deleted secret affects only its user. - create-user.sh writes the key to /run/linuxbroker-keyring/ (tmpfs, directory 0711, file 0400 owned by the user, replaced atomically), removes it when none is sent, and ignores anything that is not a key. The plan's /run/linuxbroker/keyring would sit under the directory session-control.sh closes to 0700. Releasing the lease removes the key. - The xrdp session launcher unlocks the keyring with the key, starts the Secret Service and checks the login collection's Locked property over D-Bus, each step bounded by timeout. A keyring the key cannot open is moved to ~/.local/share/linuxbroker/keyring-backup/ and a new one created, but only when the launcher started the daemon itself. The environment the daemon prints is not exported: D-Bus finds it, and its SSH_AUTH_SOCK would be wrong on Ubuntu. Every failure still starts the desktop. No PAM file changes: neither family has pam_gnome_keyring in xrdp-sesman's stack, and chpasswd runs as root with no old password, so pam_gnome_keyring cannot re-key a running keyring. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- api/README.md | 1 + api/app.py | 97 +++++++- api/config.py | 3 + api/env.example | 4 + api/tests/test_keyring.py | 201 +++++++++++++++++ deploy/bicep/main.bicep | 1 + deploy/bicep/main.json | 112 +++++++++- deploy/bicep/main.resources.bicep | 30 +++ deploy/bicep/modules/core/keyring-vault.bicep | 28 +++ linux_host/create-user.sh | 42 ++++ linux_host/manage-lease.sh | 3 + linux_host/tests/test_create_user.sh | 52 ++++- linux_host/tests/test_manage_lease.sh | 12 +- linux_host/tests/test_xrdp_startwm.sh | 207 +++++++++++++++++- linux_host/xrdp-startwm.sh | 133 ++++++++++- 15 files changed, 908 insertions(+), 18 deletions(-) create mode 100644 api/tests/test_keyring.py create mode 100644 deploy/bicep/modules/core/keyring-vault.bicep diff --git a/api/README.md b/api/README.md index 7a8adde..591c7fe 100644 --- a/api/README.md +++ b/api/README.md @@ -272,6 +272,7 @@ The API reads environment variables directly; it does not load `.env` files by i | `DOMAIN_NAME` | required for SSH actions | DNS suffix used to build `@.`. The `azd` deployment sets it to its private DNS zone (`linuxbroker.internal`) unless you supply `domainName`. | | `VAULT_URL` | required | Key Vault URL for SQL password and SSH key retrieval. | | `KEY_NAME` | required for SSH actions | Key Vault secret name containing the PEM SSH private key. | +| `KEYRING_VAULT_URL` | optional | Key Vault that holds each user's login keyring key, as a secret named `keyring-`. A checkout reads the key, or creates it the first time, and sends it to `create-user.sh` so the xrdp session launcher can unlock the user's GNOME login keyring; a profile reset replaces it. The API needs Key Vault Secrets Officer on this vault. Without the setting, no key is sent and keyrings stay locked as before. A Key Vault error never fails a checkout: that worker sends no key for the next five minutes. The `azd` deployment creates the vault (`kr…`) and sets it. | | `NFS_SHARE` | required for checkout provisioning | NFS share argument passed to `create-user.sh`; used by code but not currently listed in `env.example`. The `azd` deployment sets it to the Azure Files NFS share it provisions unless you supply `nfsShare` or set `deployNfsShare` to `false`. | | `ALLOW_LEGACY_SCOPE_ACCESS` | optional | `true` treats the portal's `access_as_user` scope as `FullAccess` while roles are assigned during an upgrade. Defaults to `false`; set through the `allowLegacyScopeAccess` deployment value. | | `GUNICORN_CMD_ARGS` | optional | Overrides the image default of `--workers 2 --threads 8 --timeout 120 --graceful-timeout 30 --keep-alive 5`. | diff --git a/api/app.py b/api/app.py index 7d5a87e..d230454 100644 --- a/api/app.py +++ b/api/app.py @@ -202,6 +202,74 @@ def get_ssh_key_path(): _ssh_key_state['fetched_at'] = time.monotonic() return path +# The account password changes at every checkout, so it cannot protect the user's login +# keyring. A key the broker keeps in the keyring vault does instead: create-user.sh leaves it +# where the xrdp session launcher unlocks the keyring with it. +KEYRING_SECRET_CONTENT_TYPE = 'linuxbroker-keyring' +KEYRING_KEY_PATTERN = re.compile(r'^[A-Za-z0-9_-]{16,128}$') +KEYRING_VAULT_BACKOFF_SECONDS = 300 + +_keyring_lock = threading.Lock() +_keyring_state = {'client': None, 'unavailable_until': 0.0} + +def get_keyring_secret_client(): + with _keyring_lock: + if _keyring_state['client'] is None: + # A checkout waits on this client, so it gives up sooner than the SDK's defaults. + _keyring_state['client'] = SecretClient( + vault_url=KEYRING_VAULT_URL, credential=get_azure_credential(), + retry_total=2, connection_timeout=5, read_timeout=10 + ) + return _keyring_state['client'] + +def get_keyring_key(uid, rotate=False): + """The key that opens the user's login keyring, or None when there is none to send. + + The secret keyring- is read from the keyring vault, or created on first use. + rotate=True writes a new version, for a profile that was just reset; the older versions + stay in the vault so the keyring moved aside with the old profile can still be opened. + + Never raises, because a keyring must not stop anyone signing in. Without + KEYRING_VAULT_URL no key is sent, and after a Key Vault error checkouts send none for five + minutes rather than each waiting on the vault. + """ + if not KEYRING_VAULT_URL or isinstance(uid, bool) or not isinstance(uid, int): + return None + if time.monotonic() < _keyring_state['unavailable_until']: + return None + + name = f"keyring-{uid}" + try: + client = get_keyring_secret_client() + if not rotate: + try: + value = client.get_secret(name).value + except Exception as e: + if getattr(e, 'status_code', None) != 404: + raise + value = None + if value and KEYRING_KEY_PATTERN.match(value): + return value + if value: + # No host was ever sent this value, so replacing it loses nothing. + logger.warning("The keyring key %s in the keyring vault is not a valid key; a new version replaces it.", name) + value = secrets.token_urlsafe(32) + client.set_secret(name, value, content_type=KEYRING_SECRET_CONTENT_TYPE) + return value + except Exception as e: + if getattr(e, 'status_code', None) == 409: + # Only this user is affected, so other checkouts keep using the vault. + logger.warning( + "Could not write the keyring key %s: a deleted secret with that name must be recovered or purged first.", name + ) + return None + _keyring_state['unavailable_until'] = time.monotonic() + KEYRING_VAULT_BACKOFF_SECONDS + logger.warning( + "Could not use the keyring key %s in the keyring vault, so checkouts send no keyring key for the next %d minutes: %s", + name, KEYRING_VAULT_BACKOFF_SECONDS // 60, e + ) + return None + _graph_token_lock = threading.Lock() _graph_token_state = {'token': None, 'expires_at': 0.0} @@ -246,6 +314,8 @@ def reset_caches(): _graph_token_state.update({'token': None, 'expires_at': 0.0}) with _ssh_key_lock: _ssh_key_state.update({'path': None, 'fetched_at': 0.0}) + with _keyring_lock: + _keyring_state.update({'client': None, 'unavailable_until': 0.0}) _checkout_event_state['missing_logged'] = False cache.clear() @@ -584,13 +654,15 @@ def run_remote_command(hostname: str, command: str, stdin_input: str = None, tim ) return result, host_fqdn -def create_or_update_remote_user(hostname: str, username: str, password: str, lease_id: str) -> bool: +def create_or_update_remote_user(hostname: str, username: str, password: str, lease_id: str, rotate_keyring_key: bool = False) -> bool: """Provision the user on the host in a single SSH session. create-user.sh --password-stdin creates the account, mounts the home, writes the lease, - adds the remote access groups and sets the password read from stdin. A host still - running the previous script rejects the extra argument with its usage text before - changing anything, and is provisioned the old way instead. + adds the remote access groups and sets the password read from stdin. The user's login + keyring key follows on a second line when the keyring vault is configured; a script that + predates it reads only the first. A host still running the previous script rejects the + extra argument with its usage text before changing anything, and is provisioned the old + way instead. """ normalized_lease_id = normalize_lease_id(lease_id) if not normalized_lease_id: @@ -611,8 +683,13 @@ def create_or_update_remote_user(hostname: str, username: str, password: str, le lease_id=shlex.quote(normalized_lease_id) ) - # Sent over stdin so the credential never appears in the remote process list or auth logs. - result, host_fqdn = run_remote_command(hostname, create_user_command, stdin_input=f"{password}\n") + # Sent over stdin so neither secret appears in the remote process list or auth logs. + stdin_input = f"{password}\n" + keyring_key = get_keyring_key(uid, rotate=rotate_keyring_key) + if keyring_key: + stdin_input += f"{keyring_key}\n" + + result, host_fqdn = run_remote_command(hostname, create_user_command, stdin_input=stdin_input) if result.returncode == 0 and CREATE_USER_RESULT_MARKER in (result.stdout or ''): return True @@ -2063,11 +2140,13 @@ def _checkout_vm(event): return error_response("No hostname or LeaseId found for the checked-out VM.", 500) # A requested profile reset is applied on a new assignment only, before create-user.sh - # mounts the home. It never stops the user signing in. + # mounts the home. It never stops the user signing in. The fresh profile gets a new + # keyring key; the old one still opens the keyring kept with the old profile. + rotate_keyring_key = False if checked_out_vm.get('ProfileResetRequested') and checked_out_vm.get('CheckoutType') == 'Assigned': - apply_pending_profile_reset(vmid, vm_hostname, username) + rotate_keyring_key = apply_pending_profile_reset(vmid, vm_hostname, username) == 'profile-reset' - if not create_or_update_remote_user(vm_hostname, username, user_password, lease_id): + if not create_or_update_remote_user(vm_hostname, username, user_password, lease_id, rotate_keyring_key): # create-user.sh may already have written the lease and mounted the home. The VM # goes back CleanupPending, so it cannot be handed to anyone else until the user # has actually been removed; the scheduled sweep retries if this attempt fails. diff --git a/api/config.py b/api/config.py index 3cdbce8..097abfc 100644 --- a/api/config.py +++ b/api/config.py @@ -66,6 +66,9 @@ def env_int(name, default, minimum=None, maximum=None): DOMAIN_NAME = os.environ.get('DOMAIN_NAME') VAULT_URL = os.environ.get('VAULT_URL') KEY_NAME = os.environ.get('KEY_NAME') +# The vault that keeps each user's login keyring key. Without it, checkouts send no key and +# the hosts behave as before. +KEYRING_VAULT_URL = (os.environ.get('KEYRING_VAULT_URL') or '').strip() or None DB_SERVER = os.environ.get('DB_SERVER') DB_DATABASE = os.environ.get('DB_DATABASE') DB_USERNAME = os.environ.get('DB_USERNAME') diff --git a/api/env.example b/api/env.example index f6e01b8..f748571 100644 --- a/api/env.example +++ b/api/env.example @@ -45,6 +45,10 @@ VAULT_URL="https://your_vault_name.vault.azure.net/" KEY_NAME="your_key_name" DB_PASSWORD_NAME="db_password_key_in_vault" +# Optional vault for the keys that unlock each user's login keyring (secrets keyring-). +# The API needs Key Vault Secrets Officer on it. Leave unset to send no keys. +# KEYRING_VAULT_URL="https://your_keyring_vault_name.vault.azure.net/" + # NFS export mounted on the Linux hosts for user home directories. Passed to # create-user.sh during checkout; leave empty if the hosts use local home directories. NFS_SHARE="your_nfs_server:/export/home" diff --git a/api/tests/test_keyring.py b/api/tests/test_keyring.py new file mode 100644 index 0000000..7418dfd --- /dev/null +++ b/api/tests/test_keyring.py @@ -0,0 +1,201 @@ +"""3.4 login keyring keys: kept in the keyring vault, sent to create-user.sh at checkout, and +rotated when a profile reset is applied.""" + +import types + +import pytest + + +LEASE_ID = "8ff6eb09-90ca-4efa-8ea1-695761f950f7" +STORED_KEY = "Aa0_-" + "k" * 38 +CREATE_USER_OK = (0, "__CREATE_USER_RESULT=ok__\n", "") + + +class VaultError(Exception): + def __init__(self, status_code): + super().__init__(f"Key Vault answered {status_code}.") + self.status_code = status_code + + +class FakeVault: + """Stands in for the keyring vault's SecretClient.""" + + def __init__(self, secrets=None, get_error=None, set_error=None): + self.secrets = dict(secrets or {}) + self.get_error = get_error + self.set_error = set_error + self.calls = [] + + def get_secret(self, name): + self.calls.append(("get", name)) + if self.get_error: + raise self.get_error + if name not in self.secrets: + raise VaultError(404) + return types.SimpleNamespace(value=self.secrets[name]) + + def set_secret(self, name, value, content_type=None): + self.calls.append(("set", name, content_type)) + if self.set_error: + raise self.set_error + self.secrets[name] = value + return types.SimpleNamespace(value=value) + + +class Host: + def __init__(self, *responses): + self.calls = [] + self._responses = list(responses) + + def __call__(self, hostname, command, stdin_input=None, timeout=120): + self.calls.append({"command": command, "stdin": stdin_input}) + returncode, stdout, stderr = self._responses.pop(0) if self._responses else CREATE_USER_OK + return types.SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr), f"avdadmin@{hostname}" + + def create_user_stdin(self): + return next(call["stdin"] for call in self.calls if "create-user.sh --password-stdin" in call["command"]) + + +@pytest.fixture +def vault(app_module, monkeypatch): + fake = FakeVault() + monkeypatch.setattr(app_module, "KEYRING_VAULT_URL", "https://kr.example.invalid/") + monkeypatch.setattr(app_module, "get_keyring_secret_client", lambda: fake) + return fake + + +@pytest.fixture +def host(app_module, monkeypatch): + fake = Host() + monkeypatch.setattr(app_module, "run_remote_command", fake) + monkeypatch.setattr(app_module, "get_or_create_uid", lambda username: 2001) + return fake + + +def provision(app_module): + return app_module.create_or_update_remote_user("lnxhost-01", "alice", "s3cr3t", LEASE_ID) + + +def test_checkout_sends_the_keyring_key_on_a_second_line(app_module, vault, host): + vault.secrets["keyring-2001"] = STORED_KEY + + assert provision(app_module) is True + + assert host.calls[0]["stdin"] == f"s3cr3t\n{STORED_KEY}\n" + assert STORED_KEY not in host.calls[0]["command"] + assert vault.calls == [("get", "keyring-2001")] + + +def test_the_first_checkout_creates_the_users_keyring_key(app_module, vault, host): + assert provision(app_module) is True + + assert vault.calls == [("get", "keyring-2001"), ("set", "keyring-2001", "linuxbroker-keyring")] + key = vault.secrets["keyring-2001"] + assert app_module.KEYRING_KEY_PATTERN.match(key) + assert host.calls[0]["stdin"] == f"s3cr3t\n{key}\n" + + +def test_a_stored_value_that_is_not_a_key_is_replaced(app_module, vault, host): + vault.secrets["keyring-2001"] = "not a key!" + + assert provision(app_module) is True + + key = vault.secrets["keyring-2001"] + assert key != "not a key!" and app_module.KEYRING_KEY_PATTERN.match(key) + assert host.calls[0]["stdin"] == f"s3cr3t\n{key}\n" + + +def test_no_key_is_sent_without_the_keyring_vault(app_module, vault, host, monkeypatch): + monkeypatch.setattr(app_module, "KEYRING_VAULT_URL", None) + + assert provision(app_module) is True + + assert host.calls[0]["stdin"] == "s3cr3t\n" + assert vault.calls == [] + + +def test_a_key_vault_failure_never_blocks_the_checkout_and_pauses_the_vault(app_module, vault, host, monkeypatch, caplog): + clock = [1000.0] + monkeypatch.setattr(app_module.time, "monotonic", lambda: clock[0]) + vault.get_error = VaultError(403) + + assert provision(app_module) is True + assert host.calls[0]["stdin"] == "s3cr3t\n" + assert "send no keyring key for the next 5 minutes" in caplog.text + + # Inside the five minutes the vault is not asked again. + clock[0] += 299 + assert provision(app_module) is True + assert host.calls[1]["stdin"] == "s3cr3t\n" + assert vault.calls == [("get", "keyring-2001")] + + vault.get_error = None + vault.secrets["keyring-2001"] = STORED_KEY + clock[0] += 2 + assert provision(app_module) is True + assert host.calls[2]["stdin"] == f"s3cr3t\n{STORED_KEY}\n" + + +def test_a_deleted_secret_affects_only_its_own_user(app_module, vault, host, caplog): + vault.set_error = VaultError(409) + + assert provision(app_module) is True + + assert host.calls[0]["stdin"] == "s3cr3t\n" + assert "must be recovered or purged" in caplog.text + assert app_module._keyring_state["unavailable_until"] == 0.0 + + +def test_the_legacy_provisioning_path_sends_no_key(app_module, vault, host): + vault.secrets["keyring-2001"] = STORED_KEY + usage = "Usage: /usr/local/bin/create-user.sh [LEASE_ID]\n" + host._responses = [(1, usage, ""), (0, "", ""), (0, "", ""), + (0, "tsusers:x:1001:", ""), (0, "alice tsusers", ""), + (0, "appusers:x:1002:", ""), (0, "alice appusers", "")] + + assert provision(app_module) is True + + assert all(STORED_KEY not in (call["stdin"] or "") for call in host.calls[1:]) + + +def checkout_row(**values): + row = { + "VMID": 5, "Hostname": "lnx-05", "IPAddress": "10.0.0.5", "Username": "bob", "AvdHost": "avd-01", + "LeaseId": LEASE_ID, "VmStatus": "CheckedOut", "CheckoutType": "Assigned", "ProfileResetRequested": True, + } + row.update(values) + return row + + +@pytest.mark.parametrize("reset_result,rotated", [ + ("profile-reset", True), + ("profile-missing", False), + ("failed", False), +]) +def test_only_an_applied_profile_reset_rotates_the_key(client, fake_db, vault, host, reset_result, rotated): + vault.secrets["keyring-2001"] = STORED_KEY + fake_db.fetchall_rows["CheckoutVm"] = [checkout_row()] + fake_db.fetchone_rows["BeginProfileReset"] = {"Result": "Ready"} + fake_db.fetchone_rows["CompleteProfileReset"] = {"Result": "Completed"} + host._responses = [(0, f"__SESSION_CONTROL_RESULT={reset_result}\n", ""), CREATE_USER_OK] + + response = client.post("/api/vms/checkout", json={"username": "bob", "avdhost": "avd-01"}) + + assert response.status_code == 200 + key = vault.secrets["keyring-2001"] + assert host.create_user_stdin() == f"{response.get_json()['password']}\n{key}\n" + if rotated: + assert vault.calls == [("set", "keyring-2001", "linuxbroker-keyring")] + assert key != STORED_KEY + else: + assert vault.calls == [("get", "keyring-2001")] + assert key == STORED_KEY + + +def test_a_reconnect_keeps_the_key(client, fake_db, vault, host): + vault.secrets["keyring-2001"] = STORED_KEY + fake_db.fetchall_rows["CheckoutVm"] = [checkout_row(CheckoutType="Reused")] + + assert client.post("/api/vms/checkout", json={"username": "bob", "avdhost": "avd-01"}).status_code == 200 + assert vault.calls == [("get", "keyring-2001")] + assert host.create_user_stdin().endswith(f"\n{STORED_KEY}\n") diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 8e50eee..9d33ae4 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -261,6 +261,7 @@ output apiAppName string = resources.outputs.apiAppName output apiUrl string = resources.outputs.apiUrl output taskAppName string = resources.outputs.taskAppName output keyVaultName string = resources.outputs.keyVaultName +output keyringVaultName string = resources.outputs.keyringVaultName output containerRegistryName string = resources.outputs.containerRegistryName output sqlServerName string = resources.outputs.sqlServerName output sqlDatabaseName string = resources.outputs.sqlDatabaseName diff --git a/deploy/bicep/main.json b/deploy/bicep/main.json index 27c3af5..c487dfc 100644 --- a/deploy/bicep/main.json +++ b/deploy/bicep/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "3452646503869980587" + "templateHash": "9307409657661009644" } }, "parameters": { @@ -567,7 +567,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "3838469552702841404" + "templateHash": "7015886803379737196" } }, "parameters": { @@ -840,6 +840,7 @@ "sqlSuffix": "[toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, parameters('appName'), parameters('environmentName'), variables('sqlLocation')))]", "storageAccountName": "[take(format('{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 24)]", "keyVaultName": "[take(format('kv{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 24)]", + "keyringVaultName": "[take(format('kr{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 24)]", "containerRegistryName": "[take(format('{0}{1}{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('suffix')), 50)]", "sqlServerName": "[take(format('sql-{0}-{1}-{2}', variables('sanitizedApp'), variables('sanitizedEnv'), variables('sqlSuffix')), 63)]", "sqlDatabaseName": "LinuxBroker", @@ -856,6 +857,7 @@ "privateEndpointSubnetName": "snet-private-endpoints", "effectiveVmResourceGroup": "[if(empty(parameters('vmHostResourceGroup')), resourceGroup().name, parameters('vmHostResourceGroup'))]", "keyVaultSecretsUserRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", + "keyVaultSecretsOfficerRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7')]", "acrPullRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", "vmPowerRoleDefinitionGuid": "40c5ff49-9181-41f8-ae61-143b0e78555e", "databasePasswordSecretName": "db-password", @@ -1043,6 +1045,20 @@ "[resourceId('Microsoft.Resources/deployments', 'apiApp')]" ] }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', variables('keyringVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', variables('keyringVaultName')), variables('apiAppName'), 'api-keyring-vault-secrets-officer')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Resources/deployments', 'apiApp'), '2025-04-01').outputs.principalId.value]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsOfficerRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'apiApp')]" + ] + }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -1873,6 +1889,88 @@ } } }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyringVault", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "keyVaultName": { + "value": "[variables('keyringVaultName')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.44.1.10279", + "templateHash": "15383843801216891735" + } + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "tags": { + "type": "object", + "defaultValue": {} + }, + "keyVaultName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[parameters('keyVaultName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "tenantId": "[subscription().tenantId]", + "enableRbacAuthorization": true, + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "publicNetworkAccess": "Enabled", + "sku": { + "family": "A", + "name": "standard" + }, + "softDeleteRetentionInDays": 90 + } + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[parameters('keyVaultName')]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + }, + "vaultUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2023-07-01').vaultUri]" + } + } + } + } + }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -2374,6 +2472,7 @@ "DOMAIN_NAME": "[variables('effectiveDomainName')]", "GRAPH_API_ENDPOINT": "[format('{0}/.default', variables('resolvedGraphEndpoint'))]", "GRAPH_ENDPOINT": "[variables('resolvedGraphEndpoint')]", + "KEYRING_VAULT_URL": "[reference(resourceId('Microsoft.Resources/deployments', 'keyringVault'), '2025-04-01').outputs.vaultUri.value]", "KEY_NAME": "[variables('linuxHostPrivateKeySecretName')]", "LINUX_HOST_ADMIN_LOGIN_NAME": "[parameters('linuxHostAdminLoginName')]", "LINUX_HOST_GROUP_ID": "[parameters('linuxHostGroupId')]", @@ -2559,6 +2658,7 @@ "dependsOn": [ "[resourceId('Microsoft.Resources/deployments', 'appServicePlan')]", "[resourceId('Microsoft.Resources/deployments', 'containerRegistry')]", + "[resourceId('Microsoft.Resources/deployments', 'keyringVault')]", "[resourceId('Microsoft.Resources/deployments', 'keyVault')]", "[resourceId('Microsoft.Resources/deployments', 'networking')]", "[resourceId('Microsoft.Resources/deployments', 'observability')]", @@ -3897,6 +3997,10 @@ "type": "string", "value": "[variables('keyVaultName')]" }, + "keyringVaultName": { + "type": "string", + "value": "[variables('keyringVaultName')]" + }, "containerRegistryName": { "type": "string", "value": "[variables('containerRegistryName')]" @@ -3958,6 +4062,10 @@ "type": "string", "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2025-04-01').outputs.keyVaultName.value]" }, + "keyringVaultName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2025-04-01').outputs.keyringVaultName.value]" + }, "containerRegistryName": { "type": "string", "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('effectiveResourceGroupName')), 'Microsoft.Resources/deployments', 'resources'), '2025-04-01').outputs.containerRegistryName.value]" diff --git a/deploy/bicep/main.resources.bicep b/deploy/bicep/main.resources.bicep index 699dabe..4eef7dc 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -124,6 +124,7 @@ var suffix = toLower(uniqueString(subscription().subscriptionId, resourceGroup() var sqlSuffix = toLower(uniqueString(subscription().subscriptionId, resourceGroup().id, appName, environmentName, sqlLocation)) var storageAccountName = take('${sanitizedApp}${sanitizedEnv}${suffix}', 24) var keyVaultName = take('kv${sanitizedApp}${sanitizedEnv}${suffix}', 24) +var keyringVaultName = take('kr${sanitizedApp}${sanitizedEnv}${suffix}', 24) var containerRegistryName = take('${sanitizedApp}${sanitizedEnv}${suffix}', 50) var sqlServerName = take('sql-${sanitizedApp}-${sanitizedEnv}-${sqlSuffix}', 63) var sqlDatabaseName = 'LinuxBroker' @@ -140,6 +141,8 @@ var avdSubnetName = 'snet-avd-hosts' var privateEndpointSubnetName = 'snet-private-endpoints' var effectiveVmResourceGroup = empty(vmHostResourceGroup) ? resourceGroup().name : vmHostResourceGroup var keyVaultSecretsUserRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +// Key Vault Secrets Officer: the API creates and rotates the keyring secrets. +var keyVaultSecretsOfficerRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') var acrPullRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') // Desktop Virtualization Power On Off Contributor: start, power off, and read VMs, without write or run command. var vmPowerRoleDefinitionGuid = '40c5ff49-9181-41f8-ae61-143b0e78555e' @@ -220,6 +223,17 @@ module keyVault 'modules/core/key-vault.bicep' = { } } +// A vault of its own, so the API can write the keyring keys without being able to change the +// database password or the host SSH key. +module keyringVault 'modules/core/keyring-vault.bicep' = { + name: 'keyringVault' + params: { + location: location + tags: tags + keyVaultName: keyringVaultName + } +} + module sql 'modules/core/sql-database.bicep' = { name: 'sql' params: { @@ -252,6 +266,10 @@ resource keyVaultResource 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: keyVaultName } +resource keyringVaultResource 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyringVaultName +} + resource storageAccountResource 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { name: storageAccountName } @@ -387,6 +405,7 @@ var apiSettings = { DOMAIN_NAME: effectiveDomainName GRAPH_API_ENDPOINT: '${resolvedGraphEndpoint}/.default' GRAPH_ENDPOINT: resolvedGraphEndpoint + KEYRING_VAULT_URL: keyringVault.outputs.vaultUri KEY_NAME: linuxHostPrivateKeySecretName LINUX_HOST_ADMIN_LOGIN_NAME: linuxHostAdminLoginName LINUX_HOST_GROUP_ID: linuxHostGroupId @@ -516,6 +535,16 @@ resource apiKeyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04 } } +resource apiKeyringVaultSecretsOfficer 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyringVaultResource.id, apiAppName, 'api-keyring-vault-secrets-officer') + scope: keyringVaultResource + properties: { + principalId: apiApp.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsOfficerRoleDefinitionId + } +} + // The API starts and stops hosts from the portal and for scaling rules. module apiVmPowerRole 'modules/core/resource-group-role-assignment.bicep' = { name: 'apiVmPowerRole' @@ -583,6 +612,7 @@ output apiAppName string = apiAppName output apiUrl string = 'https://${apiAppName}.${resolvedAppServiceDomain}/api' output taskAppName string = taskAppName output keyVaultName string = keyVaultName +output keyringVaultName string = keyringVaultName output containerRegistryName string = containerRegistryName output sqlServerName string = sql.outputs.sqlServerName output sqlDatabaseName string = sql.outputs.databaseName diff --git a/deploy/bicep/modules/core/keyring-vault.bicep b/deploy/bicep/modules/core/keyring-vault.bicep new file mode 100644 index 0000000..15e9187 --- /dev/null +++ b/deploy/bicep/modules/core/keyring-vault.bicep @@ -0,0 +1,28 @@ +param location string = resourceGroup().location +param tags object = {} +param keyVaultName string + +// Holds one secret per user, keyring-, with the key that opens that user's login keyring. +// The API creates the secrets at checkout, so the template adds none. +resource keyringVault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: keyVaultName + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + enableRbacAuthorization: true + enabledForDeployment: false + enabledForDiskEncryption: false + enabledForTemplateDeployment: false + publicNetworkAccess: 'Enabled' + sku: { + family: 'A' + name: 'standard' + } + softDeleteRetentionInDays: 90 + } +} + +output name string = keyringVault.name +output id string = keyringVault.id +output vaultUri string = keyringVault.properties.vaultUri diff --git a/linux_host/create-user.sh b/linux_host/create-user.sh index 30a1e6d..79201b7 100644 --- a/linux_host/create-user.sh +++ b/linux_host/create-user.sh @@ -12,6 +12,8 @@ NFS_MOUNT_ROOT="/awipsprofiles" NFS_OPTIONS="vers=4,minorversion=1,sec=sys,nconnect=4" LOGFILE=/var/log/createuser.log LEASE_DIRECTORY="/var/lib/linuxbroker-release-session/leases" +# The key that opens each user's login keyring, left on tmpfs for the xrdp session launcher. +KEYRING_KEY_DIRECTORY="/run/linuxbroker-keyring" PASSWORD_MODE="false" SCRIPT_MOUNTED_NFS_ROOT="false" @@ -78,6 +80,36 @@ ensure_user_group_membership() { run_checked "Failed to add $USERNAME to group $group_name." usermod -aG "$group_name" "$USERNAME" } +# Leaves the user's keyring key where the xrdp session launcher reads it, or removes a key an +# earlier checkout left when none was sent. The key only unlocks the keyring, so a failure is +# logged and the sign-in goes ahead. +store_keyring_key() { + local key_file="$KEYRING_KEY_DIRECTORY/$USERNAME" tmp + + if [ -z "$KEYRING_KEY" ]; then + rm -f "$key_file" 2>/dev/null || log "Could not remove the old keyring key of $USERNAME." + return 0 + fi + + # Anyone may open a key by name, but only its owner can read it and nobody can list them. + if ! mkdir -p "$KEYRING_KEY_DIRECTORY" || ! chown root:root "$KEYRING_KEY_DIRECTORY" \ + || ! chmod 711 "$KEYRING_KEY_DIRECTORY"; then + log "Could not prepare $KEYRING_KEY_DIRECTORY, so the keyring of $USERNAME stays locked." + return 0 + fi + if ! tmp=$(mktemp "$KEYRING_KEY_DIRECTORY/.$USERNAME.XXXXXX"); then + log "Could not write the keyring key of $USERNAME." + return 0 + fi + if ! printf '%s\n' "$KEYRING_KEY" > "$tmp" || ! chown "$USERNAME" "$tmp" || ! chmod 400 "$tmp" \ + || ! mv -f "$tmp" "$key_file"; then + rm -f "$tmp" + log "Could not write the keyring key of $USERNAME." + return 0 + fi + log "Stored the keyring key of $USERNAME." +} + if [ "${1:-}" = "--password-stdin" ]; then if [ $# -ne 5 ]; then usage @@ -141,6 +173,14 @@ if [ "$PASSWORD_MODE" = "true" ]; then if [ -z "$PASSWORD" ]; then fail "Password was not supplied on stdin." fi + + # The broker sends the user's keyring key on a second line when it has a keyring vault. + KEYRING_KEY="" + IFS= read -r KEYRING_KEY || true + if [ -n "$KEYRING_KEY" ] && ! [[ "$KEYRING_KEY" =~ ^[A-Za-z0-9_-]{16,128}$ ]]; then + log "Ignoring a keyring key for $USERNAME that is not a valid key." + KEYRING_KEY="" + fi fi # Create local user if it doesn't exist. Ubuntu's useradd would give the user /bin/sh, which @@ -229,6 +269,8 @@ fi if [ "$PASSWORD_MODE" = "true" ]; then printf '%s:%s\n' "$USERNAME" "$PASSWORD" | chpasswd || fail "Failed to set password for $USERNAME." unset PASSWORD + store_keyring_key + unset KEYRING_KEY if [ "$SCRIPT_MOUNTED_NFS_ROOT" = "true" ]; then run_checked "Failed to unmount $NFS_MOUNT_ROOT." umount "$NFS_MOUNT_ROOT" SCRIPT_MOUNTED_NFS_ROOT="false" diff --git a/linux_host/manage-lease.sh b/linux_host/manage-lease.sh index 4b95567..9965963 100644 --- a/linux_host/manage-lease.sh +++ b/linux_host/manage-lease.sh @@ -17,6 +17,8 @@ set -u LINUXBROKER_AGENT_VERSION="1.1.0" LEASE_DIRECTORY="/var/lib/linuxbroker-release-session/leases" +# Where create-user.sh leaves the key that opens each user's login keyring. +KEYRING_KEY_DIRECTORY="/run/linuxbroker-keyring" HOME_ROOT="/home" usage() { @@ -116,6 +118,7 @@ release_lease() { terminate_leftover_processes || exit 1 unmount_user_home || exit 1 + rm -f "$KEYRING_KEY_DIRECTORY/$USERNAME" rm -f "$LEASE_FILE" echo "__LEASE_ACTION=cleared__" } diff --git a/linux_host/tests/test_create_user.sh b/linux_host/tests/test_create_user.sh index 6ac2166..bbf777d 100644 --- a/linux_host/tests/test_create_user.sh +++ b/linux_host/tests/test_create_user.sh @@ -124,9 +124,59 @@ legacy_fixture_rejects_new_form() { ! id "$user" >/dev/null 2>&1 || fail "$user should not exist" } +# The broker sends the user's keyring key on a second line, for the xrdp session launcher. +keyring_key_is_left_for_the_session() { + local user="lbtestcu10" key="Lbt3stKeyringKey_AAAAAAAAAAAAAAAAAAAAAAAAAA" key_file out + local saved="" + setup_case + cleanup_user "$user" + if [ -e /run/linuxbroker-keyring ]; then + saved="/run/linuxbroker-keyring.lbtest-saved" + rm -rf "$saved" + mv /run/linuxbroker-keyring "$saved" + fi + key_file="/run/linuxbroker-keyring/$user" + + out=$(printf 'pw\n%s\n' "$key" | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE") \ + || fail "provisioning with a keyring key failed" + assert_contains "$out" "__CREATE_USER_RESULT=ok__" + assert_eq "$(cat "$key_file")" "$key" + assert_eq "$(stat -c '%a %U' "$key_file")" "400 $user" + assert_eq "$(stat -c '%a %U' /run/linuxbroker-keyring)" "711 root" + assert_eq "$(find /run/linuxbroker-keyring -name ".$user.*" | wc -l | tr -d ' ')" "0" "no temporary file is left" + assert_not_contains_file /var/log/createuser.log "$key" + + # A reconnect sends the same key again; a new one replaces it. + printf 'pw\n%s\n' "${key/A/B}" | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE" >/dev/null \ + || fail "provisioning with a new keyring key failed" + assert_eq "$(cat "$key_file")" "${key/A/B}" + + # Anything that is not a key is ignored, and removes the old one. + for bad in "short" "has space in it, which no key has" "$(printf 'x%.0s' {1..129})"; do + printf 'pw\n%s\n' "$key" | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE" >/dev/null + printf 'pw\n%s\n' "$bad" | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE" >/dev/null \ + || fail "a malformed keyring key failed the checkout" + assert_not_exists "$key_file" + done + assert_file_contains /var/log/createuser.log "Ignoring a keyring key for $user that is not a valid key." + + # A broker without a keyring vault sends none, which also removes a key left earlier. + printf 'pw\n%s\n' "$key" | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE" >/dev/null + printf 'pw\n' | bash "$SCRIPT" --password-stdin nfs.example:/profiles 21010 "$user" "$LEASE" >/dev/null \ + || fail "provisioning without a keyring key failed" + assert_not_exists "$key_file" + + cleanup_user "$user" + rm -rf /run/linuxbroker-keyring + if [ -n "$saved" ]; then + mv "$saved" /run/linuxbroker-keyring + fi +} + new_form_success validation_failures mount_failure legacy_form_still_works existing_users_get_bash_instead_of_sh -legacy_fixture_rejects_new_form \ No newline at end of file +legacy_fixture_rejects_new_form +keyring_key_is_left_for_the_session \ No newline at end of file diff --git a/linux_host/tests/test_manage_lease.sh b/linux_host/tests/test_manage_lease.sh index 1c14278..2b1fa9e 100644 --- a/linux_host/tests/test_manage_lease.sh +++ b/linux_host/tests/test_manage_lease.sh @@ -6,6 +6,7 @@ set -uo pipefail SCRIPT="$ROOT_DIR/linux_host/manage-lease.sh" LEASE_DIR="/var/lib/linuxbroker-release-session/leases" LEASE="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +KEY_FILE="/run/linuxbroker-keyring/lbmluser" setup_case() { reset_work @@ -14,8 +15,10 @@ setup_case() { install_process_shims export FAKE_CALLS="$WORK_DIR/calls.log" : > "$FAKE_CALLS" - mkdir -p "$LEASE_DIR" /home/lbmluser + mkdir -p "$LEASE_DIR" /home/lbmluser /run/linuxbroker-keyring rm -f "$LEASE_DIR"/*.lease + # The keyring key create-user.sh left at checkout. + printf 'Lbt3stKeyringKey_AAAAAAAAAAAAAAAA\n' > "$KEY_FILE" } write_lease() { printf '%s\n' "$LEASE" > "$LEASE_DIR/$1.lease"; } @@ -43,6 +46,7 @@ export FAKE_LOGINCTL_STATE=active out=$(bash "$SCRIPT" clear lbmluser "$LEASE") assert_contains "$out" "__LEASE_ACTION=in-use__" assert_file_exists "$LEASE_DIR/lbmluser.lease" +assert_file_exists "$KEY_FILE" ! grep -Fq "loginctl terminate-user lbmluser" "$FAKE_CALLS" || fail "signed-in user should not be terminated" ! grep -Fq "pkill -KILL -u lbmluser" "$FAKE_CALLS" || fail "signed-in user processes should not be killed" unset FAKE_LOGINCTL_STATE @@ -55,6 +59,7 @@ out=$(bash "$SCRIPT" clear lbmluser "$LEASE") unset FAKE_MOUNTPOINT_SEQUENCE assert_contains "$out" "__LEASE_ACTION=cleared__" assert_not_exists "$LEASE_DIR/lbmluser.lease" +assert_not_exists "$KEY_FILE" assert_call_before "loginctl terminate-user lbmluser" "umount -l /home/lbmluser" assert_call_before "pkill -KILL -u lbmluser" "umount -l /home/lbmluser" @@ -66,6 +71,7 @@ out=$(bash "$SCRIPT" clear-any lbmluser) unset FAKE_MOUNTPOINT_SEQUENCE assert_contains "$out" "__LEASE_ACTION=cleared__" assert_not_exists "$LEASE_DIR/lbmluser.lease" +assert_not_exists "$KEY_FILE" assert_call_before "loginctl terminate-user lbmluser" "umount -l /home/lbmluser" assert_call_before "pkill -KILL -u lbmluser" "umount -l /home/lbmluser" @@ -74,10 +80,12 @@ write_lease lbmluser out=$(bash "$SCRIPT" clear lbmluser "ffffffff-1111-2222-3333-444444444444") assert_contains "$out" "__LEASE_ACTION=mismatch__" assert_file_exists "$LEASE_DIR/lbmluser.lease" +assert_file_exists "$KEY_FILE" setup_case out=$(bash "$SCRIPT" clear lbmluser "$LEASE") assert_contains "$out" "__LEASE_ACTION=missing__" if bash "$SCRIPT" read '../bad' >/dev/null 2>&1; then fail "invalid username accepted"; fi -if bash "$SCRIPT" clear lbmluser not-a-guid >/dev/null 2>&1; then fail "invalid lease accepted"; fi \ No newline at end of file +if bash "$SCRIPT" clear lbmluser not-a-guid >/dev/null 2>&1; then fail "invalid lease accepted"; fi +rm -rf /run/linuxbroker-keyring \ No newline at end of file diff --git a/linux_host/tests/test_xrdp_startwm.sh b/linux_host/tests/test_xrdp_startwm.sh index 25cc546..3d734ab 100644 --- a/linux_host/tests/test_xrdp_startwm.sh +++ b/linux_host/tests/test_xrdp_startwm.sh @@ -15,7 +15,8 @@ FAKE_SESMAN_PID="" # Everything the tests create. Whatever was there before is set aside and put back. TOUCHED=(/etc/xrdp /usr/libexec/xrdp /etc/polkit-1 /etc/X11 /usr/share/gnome-session /etc/linuxbroker "$LAUNCHER" "$SHIM_DIR/systemctl" "$SHIM_DIR/gnome-session" "$SHIM_DIR/startxfce4" "$SHIM_DIR/mate-session" - "$SHIM_DIR/logger") + "$SHIM_DIR/logger" "$SHIM_DIR/gnome-keyring-daemon" "$SHIM_DIR/gdbus" "$SHIM_DIR/pgrep" "$SHIM_DIR/pkill" + /run/linuxbroker-keyring) stop_fake_sesman() { if [ -n "$FAKE_SESMAN_PID" ]; then @@ -123,7 +124,8 @@ fake_session_script() { { echo "ran=$label" echo "args=\$*" - for name in DESKTOP_SESSION XDG_SESSION_DESKTOP XDG_CURRENT_DESKTOP XDG_SESSION_TYPE GNOME_SHELL_SESSION_MODE LBTEST_PROFILE; do + for name in DESKTOP_SESSION XDG_SESSION_DESKTOP XDG_CURRENT_DESKTOP XDG_SESSION_TYPE GNOME_SHELL_SESSION_MODE LBTEST_PROFILE \ + GNOME_KEYRING_CONTROL SSH_AUTH_SOCK; do echo "\$name=\${!name:-}" done } > "\${LBTEST_SESSION_OUT:-/dev/null}" @@ -339,10 +341,11 @@ install_desktop_shim() { } # Starts a session the way xrdp-sesman does: as the user, in their home, with no arguments. +# Arguments are extra NAME=VALUE pairs for the session's environment. run_session() { rm -f "$WORK_DIR/session.out" (cd "$WORK_DIR/home" && env -i PATH="$SHIM_DIR:/usr/bin:/bin" HOME="$WORK_DIR/home" FAKE_CALLS="$FAKE_CALLS" \ - LBTEST_SESSION_OUT="$WORK_DIR/session.out" bash "$LAUNCHER") + LBTEST_SESSION_OUT="$WORK_DIR/session.out" "$@" bash "$LAUNCHER") [ -f "$WORK_DIR/session.out" ] || fail "no session script ran" } @@ -527,6 +530,200 @@ test_an_unusable_record_falls_back() { assert_eq "$(session_value args)" "" } +# --------------------------------------------------------------------------- +# The login keyring. + +KEY="Lbt3stKeyringKey_AAAAAAAAAAAAAAAAAAAAAAAAAA" +BUS="unix:path=/run/user/0/bus" +KEYRING="$WORK_DIR/home/.local/share/keyrings/login.keyring" +BACKUPS="$WORK_DIR/home/.local/share/linuxbroker/keyring-backup" + +# Stand-ins for gnome-keyring-daemon and the Secret Service. A keyring file holds the key that +# opens it, or UNENCRYPTED; $FAKE_GKD_STATE records whether a daemon runs and the keyring is open. +install_keyring_shims() { + export FAKE_GKD_STATE="$WORK_DIR/gkd" + mkdir -p "$FAKE_GKD_STATE" + cat > "$SHIM_DIR/gnome-keyring-daemon" <<'SHIM' +#!/bin/bash +state="$FAKE_GKD_STATE" +ring="$HOME/.local/share/keyrings/login.keyring" +echo "gnome-keyring-daemon $* bus=${DBUS_SESSION_BUS_ADDRESS:-} runtime=${XDG_RUNTIME_DIR:-}" >> "$FAKE_CALLS" +case "$1" in + --unlock) + if [ "${FAKE_GKD_HANG:-0}" = "1" ]; then sleep 30; fi + IFS= read -r key || true + : > "$state/running" + if [ ! -e "$ring" ]; then + mkdir -p "$(dirname "$ring")" + printf '%s' "$key" > "$ring" + fi + content=$(cat "$ring") + if [ "$content" = "$key" ] || [ "$content" = "UNENCRYPTED" ]; then : > "$state/unlocked"; else rm -f "$state/unlocked"; fi + ;; + --start) + : > "$state/running" + echo "GNOME_KEYRING_CONTROL=${XDG_RUNTIME_DIR:-}/keyring" + echo "SSH_AUTH_SOCK=${XDG_RUNTIME_DIR:-}/keyring/ssh" + ;; +esac +exit 0 +SHIM + cat > "$SHIM_DIR/gdbus" <<'SHIM' +#!/bin/bash +echo "gdbus $* bus=${DBUS_SESSION_BUS_ADDRESS:-}" >> "$FAKE_CALLS" +if [ "${FAKE_GDBUS_FAIL:-0}" = "1" ] || [ ! -e "$FAKE_GKD_STATE/running" ]; then exit 1; fi +case "$*" in + *" Collections"*) echo "(<[objectpath '/org/freedesktop/secrets/collection/login']>,)" ;; + *" Locked"*) if [ -e "$FAKE_GKD_STATE/unlocked" ]; then echo "(,)"; else echo "(,)"; fi ;; +esac +SHIM + cat > "$SHIM_DIR/pgrep" <<'SHIM' +#!/bin/bash +echo "pgrep $*" >> "$FAKE_CALLS" +[ -e "$FAKE_GKD_STATE/running" ] +SHIM + cat > "$SHIM_DIR/pkill" <<'SHIM' +#!/bin/bash +echo "pkill $*" >> "$FAKE_CALLS" +rm -f "$FAKE_GKD_STATE/running" "$FAKE_GKD_STATE/unlocked" +SHIM + chmod 755 "$SHIM_DIR/gnome-keyring-daemon" "$SHIM_DIR/gdbus" "$SHIM_DIR/pgrep" "$SHIM_DIR/pkill" +} + +write_key() { + mkdir -p /run/linuxbroker-keyring + printf '%s\n' "$1" > /run/linuxbroker-keyring/root + chmod 400 /run/linuxbroker-keyring/root +} + +# The daemon of a previous session has gone. +end_keyring_daemon() { + rm -f "$FAKE_GKD_STATE/running" "$FAKE_GKD_STATE/unlocked" +} + +setup_keyring_case() { + setup_case + setup_debian_session + printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" + install_keyring_shims + write_key "$KEY" +} + +assert_desktop_started() { + assert_eq "$(session_value ran)" "xsession" "${1:-}" + assert_eq "$(session_value args)" "gnome-session --session=ubuntu" "${1:-}" +} + +backup_count() { + find "$BACKUPS" -name 'login-*.keyring' 2>/dev/null | wc -l | tr -d ' ' +} + +test_the_login_keyring_opens_with_the_brokers_key() { + setup_keyring_case + + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started + assert_eq "$(cat "$KEYRING")" "$KEY" "the first session creates the keyring with the key" + assert_file_contains "$FAKE_CALLS" "gnome-keyring-daemon --unlock bus=$BUS runtime=/run/user/0" + assert_file_contains "$FAKE_CALLS" "gnome-keyring-daemon --start --components=secrets bus=$BUS" + assert_file_contains "$FAKE_CALLS" "Unlocked the login keyring of root." + assert_eq "$(session_value GNOME_KEYRING_CONTROL)" "" "the desktop's environment is unchanged" + assert_eq "$(session_value SSH_AUTH_SOCK)" "" + assert_not_contains_file "$FAKE_CALLS" "$KEY" + + # The next session, with the same key, opens the same keyring. + end_keyring_daemon + : > "$FAKE_CALLS" + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started + assert_file_contains "$FAKE_CALLS" "Unlocked the login keyring of root." + assert_eq "$(backup_count)" "0" + + # An unencrypted keyring is always open, and is kept. + end_keyring_daemon + printf 'UNENCRYPTED' > "$KEYRING" + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_eq "$(cat "$KEYRING")" "UNENCRYPTED" + assert_eq "$(backup_count)" "0" +} + +test_a_keyring_the_key_cannot_open_is_moved_aside() { + local backup + setup_keyring_case + mkdir -p "$(dirname "$KEYRING")" + printf 'password-of-an-earlier-checkout' > "$KEYRING" + + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started + assert_eq "$(backup_count)" "1" + backup=$(find "$BACKUPS" -name 'login-*.keyring') + assert_eq "$(cat "$backup")" "password-of-an-earlier-checkout" "the old keyring is kept" + assert_eq "$(stat -c %a "$BACKUPS")" "700" + assert_eq "$(cat "$KEYRING")" "$KEY" "a new keyring opens with the key" + assert_file_contains "$FAKE_CALLS" "systemctl --user stop gnome-keyring-daemon.service" + assert_file_contains "$FAKE_CALLS" "pkill -u 0 -x gnome-keyring-d" + assert_file_contains "$FAKE_CALLS" "Moved a login keyring the key does not open to $backup, and created a new one for root." + assert_not_contains_file "$FAKE_CALLS" "$KEY" +} + +test_a_keyring_in_use_elsewhere_is_left_alone() { + setup_keyring_case + mkdir -p "$(dirname "$KEYRING")" + printf 'a-password-the-user-chose' > "$KEYRING" + # Another session of the user already runs a keyring daemon. + : > "$FAKE_GKD_STATE/running" + + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started + assert_eq "$(cat "$KEYRING")" "a-password-the-user-chose" + assert_eq "$(backup_count)" "0" + assert_not_contains_file "$FAKE_CALLS" "pkill" + assert_file_contains "$FAKE_CALLS" "The login keyring of root stays locked: the key does not open it." + + # When the keyring's state cannot be read, nothing is moved either. + end_keyring_daemon + : > "$FAKE_CALLS" + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" FAKE_GDBUS_FAIL=1 + assert_desktop_started + assert_eq "$(backup_count)" "0" + assert_file_contains "$FAKE_CALLS" "Could not tell whether the login keyring of root is unlocked." +} + +test_without_a_usable_key_the_desktop_starts_as_before() { + local started elapsed + setup_keyring_case + + rm -f /run/linuxbroker-keyring/root + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started "no key" + assert_not_contains_file "$FAKE_CALLS" "gnome-keyring-daemon" + + write_key "not a key!" + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started "a malformed key" + assert_not_contains_file "$FAKE_CALLS" "gnome-keyring-daemon" + assert_file_contains "$FAKE_CALLS" "Ignoring /run/linuxbroker-keyring/root: it does not hold a keyring key." + + # No session bus: /run/user/0/bus is not a socket here. + write_key "$KEY" + run_session FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started "no session bus" + assert_not_contains_file "$FAKE_CALLS" "gnome-keyring-daemon" + assert_file_contains "$FAKE_CALLS" "the session has no D-Bus session bus" + + rm -f "$SHIM_DIR/gnome-keyring-daemon" + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" + assert_desktop_started "no keyring daemon installed" + + # A daemon that hangs delays the desktop by the step timeout, no more. + install_keyring_shims + started=$(date +%s) + run_session DBUS_SESSION_BUS_ADDRESS="$BUS" FAKE_GKD_STATE="$FAKE_GKD_STATE" FAKE_GKD_HANG=1 + elapsed=$(( $(date +%s) - started )) + assert_desktop_started "a hung keyring daemon" + [ "$elapsed" -lt 20 ] || fail "a hung keyring daemon held the desktop for $elapsed seconds" +} + test_install_on_ubuntu test_install_on_rhel test_install_reads_sesman_ini_as_xrdp_does @@ -537,5 +734,9 @@ test_xfce_and_mate_on_debian test_rhel_runs_its_own_script_for_gnome test_xfce_and_mate_on_rhel test_an_unusable_record_falls_back +test_the_login_keyring_opens_with_the_brokers_key +test_a_keyring_the_key_cannot_open_is_moved_aside +test_a_keyring_in_use_elsewhere_is_left_alone +test_without_a_usable_key_the_desktop_starts_as_before echo "xrdp-startwm.sh tests passed" diff --git a/linux_host/xrdp-startwm.sh b/linux_host/xrdp-startwm.sh index 220487a..5631d1b 100644 --- a/linux_host/xrdp-startwm.sh +++ b/linux_host/xrdp-startwm.sh @@ -7,7 +7,8 @@ # /etc/linuxbroker/desktop.conf names, which the host bootstrap writes: the distribution's own # script cannot start Ubuntu's session on Xorg, or choose between desktops installed side by # side. Anything this script does not handle, including a host without desktop.conf, runs the -# distribution's script exactly as before. +# distribution's script exactly as before. First, it unlocks the user's login keyring with the +# key create-user.sh left for them, when there is one. # # --install, as root, points DefaultWindowManager in /etc/xrdp/sesman.ini at this script. It # records the script it replaces in /etc/linuxbroker/xrdp-startwm.conf, keeps the original @@ -317,6 +318,135 @@ log_session() { fi } +# The login keyring. xrdp-sesman has no keyring PAM module, and the account password changes +# at every checkout anyway, so nothing else could unlock it: every application that stores a +# secret would ask for a password the user never had. create-user.sh leaves a key the broker +# keeps for the user instead, and the keyring is unlocked with it before the desktop starts. +# Every step is best effort and bounded: the desktop starts whatever happens here. + +KEYRING_KEY_DIRECTORY="/run/linuxbroker-keyring" +KEYRING_STEP_TIMEOUT_SECONDS=5 +KEYRING_RUNTIME_DIRECTORY="" +KEYRING_BUS="" + +# Runs a keyring command on the user's session bus, which the desktop's own keyring components +# use too, without changing the environment the desktop starts with. +keyring_command() { + timeout "$KEYRING_STEP_TIMEOUT_SECONDS" env XDG_RUNTIME_DIR="$KEYRING_RUNTIME_DIRECTORY" \ + DBUS_SESSION_BUS_ADDRESS="$KEYRING_BUS" "$@" +} + +keyring_daemon_running() { + # The kernel keeps the first 15 characters of gnome-keyring-daemon as its name. + pgrep -u "$(id -u)" -x gnome-keyring-d >/dev/null 2>&1 +} + +# Unlocks the login keyring with the key, creating it when there is none, and starts the +# Secret Service applications use. Both commands succeed even when the key is wrong. +open_login_keyring() { + printf '%s' "$1" | keyring_command gnome-keyring-daemon --unlock >/dev/null 2>&1 + keyring_command gnome-keyring-daemon --start --components=secrets >/dev/null 2>&1 +} + +# Prints true or false for the login keyring's Locked property, or nothing when it cannot be +# read. Asking for the collections first makes the daemon offer a keyring --unlock created. +login_keyring_locked() { + local reply + + keyring_command gdbus call --session --timeout 3 --dest org.freedesktop.secrets \ + --object-path /org/freedesktop/secrets --method org.freedesktop.DBus.Properties.Get \ + org.freedesktop.Secret.Service Collections >/dev/null 2>&1 + reply=$(keyring_command gdbus call --session --timeout 3 --dest org.freedesktop.secrets \ + --object-path /org/freedesktop/secrets/collection/login --method org.freedesktop.DBus.Properties.Get \ + org.freedesktop.Secret.Collection Locked 2>/dev/null) + case "$reply" in + *true*) echo true ;; + *false*) echo false ;; + esac +} + +stop_keyring_daemon() { + local attempts=0 + + # Ubuntu runs the daemon as a user service, which would otherwise restart it. + keyring_command systemctl --user stop gnome-keyring-daemon.service >/dev/null 2>&1 + pkill -u "$(id -u)" -x gnome-keyring-d >/dev/null 2>&1 + while keyring_daemon_running; do + [ "$attempts" -ge 10 ] && return 1 + sleep 0.5 + attempts=$((attempts + 1)) + done +} + +unlock_keyring() { + local user home key key_file keyring backup_directory backup started_here=0 + + user=$(id -un 2>/dev/null) || return 0 + key_file="$KEYRING_KEY_DIRECTORY/$user" + [ -r "$key_file" ] || return 0 + command -v gnome-keyring-daemon >/dev/null 2>&1 || return 0 + + key=$(head -c 256 "$key_file" 2>/dev/null | tr -d '\r\n') + if ! [[ "$key" =~ ^[A-Za-z0-9_-]{16,128}$ ]]; then + log_session "Ignoring $key_file: it does not hold a keyring key." + return 0 + fi + + KEYRING_RUNTIME_DIRECTORY="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + KEYRING_BUS="${DBUS_SESSION_BUS_ADDRESS:-}" + if [ -z "$KEYRING_BUS" ] && [ -S "$KEYRING_RUNTIME_DIRECTORY/bus" ]; then + KEYRING_BUS="unix:path=$KEYRING_RUNTIME_DIRECTORY/bus" + fi + if [ -z "$KEYRING_BUS" ]; then + log_session "The login keyring of $user was not unlocked: the session has no D-Bus session bus." + return 0 + fi + + keyring_daemon_running || started_here=1 + open_login_keyring "$key" + case "$(login_keyring_locked)" in + false) + log_session "Unlocked the login keyring of $user." + return 0 + ;; + true) ;; + *) + log_session "Could not tell whether the login keyring of $user is unlocked." + return 0 + ;; + esac + + # The keyring is protected by something other than the key: the password of an earlier + # checkout, or one the user chose. Nothing can open it, so it is moved aside and a new one + # created, unless a daemon this script did not start is using it. + home="${HOME:-$(getent passwd "$user" | cut -d: -f6)}" + keyring="${XDG_DATA_HOME:-$home/.local/share}/keyrings/login.keyring" + if [ "$started_here" -ne 1 ] || [ ! -f "$keyring" ]; then + log_session "The login keyring of $user stays locked: the key does not open it." + return 0 + fi + + backup_directory="$home/.local/share/linuxbroker/keyring-backup" + backup="$backup_directory/login-$(date -u +%Y%m%dT%H%M%SZ).keyring" + [ ! -e "$backup" ] || backup="${backup%.keyring}-$$.keyring" + if ! stop_keyring_daemon; then + log_session "The login keyring of $user stays locked: the keyring daemon did not stop." + return 0 + fi + if ! mkdir -p "$backup_directory" || ! chmod 700 "$backup_directory" || ! mv "$keyring" "$backup"; then + log_session "The login keyring of $user stays locked: it could not be moved aside." + open_login_keyring "$key" + return 0 + fi + + open_login_keyring "$key" + if [ "$(login_keyring_locked)" = "false" ]; then + log_session "Moved a login keyring the key does not open to $backup, and created a new one for $user." + else + log_session "Moved a login keyring the key does not open to $backup, but the new one for $user is not unlocked." + fi +} + # The desktop desktop.conf names, or nothing when it names none this script starts. The file # is read, never sourced. configured_desktop() { @@ -408,6 +538,7 @@ run_original() { start_session() { local desktop starter="" + unlock_keyring desktop=$(configured_desktop) if [ -n "$desktop" ]; then if [ -d /etc/X11/Xsession.d ] && [ -x /etc/X11/Xsession ]; then From 1c0af388145aa0eb83251ec0f08136475b034522 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 22:01:45 -0400 Subject: [PATCH 13/19] Release host agent 1.2.0 Every script in linux_host declares 1.2.0 and the API expects it, so fleet health flags hosts as agent-outdated until Migrate-LinuxHostReleaseAgent.ps1 has updated them. 1.2.0 carries the Phase 3 host changes: the merged release agent, the xrdp session launcher with its keyring unlock, the keyring key in create-user.sh and manage-lease.sh, and the xpra cleanup. The heartbeat now also reports the version of xrdp-startwm.sh, and the API keeps it. Test fixtures that model a current host move to 1.2.0; patching still needs only 1.1.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- api/app.py | 2 +- api/config.py | 2 +- api/tests/test_host_list.py | 2 +- api/tests/test_phase2_foundations.py | 10 +++++----- api/tests/test_trends.py | 2 +- api/tests_integration/test_trends_against_sql.py | 2 +- linux_host/apply-host-settings.sh | 2 +- linux_host/create-user.sh | 2 +- linux_host/manage-lease.sh | 2 +- linux_host/patch-host.sh | 2 +- linux_host/session-control.sh | 2 +- .../session_release_buffer/logind-session-watcher.sh | 2 +- linux_host/session_release_buffer/release-session.sh | 4 ++-- linux_host/session_release_buffer/xrdp-who-xorg.sh | 2 +- linux_host/tests/test_heartbeat.sh | 5 +++-- linux_host/xrdp-startwm.sh | 2 +- 16 files changed, 23 insertions(+), 22 deletions(-) diff --git a/api/app.py b/api/app.py index d230454..53cc23e 100644 --- a/api/app.py +++ b/api/app.py @@ -4412,7 +4412,7 @@ def get_host_settings_history(): HEARTBEAT_SCRIPTS = ( 'release-session.sh', 'logind-session-watcher.sh', 'xrdp-who-xorg.sh', 'create-user.sh', 'manage-lease.sh', 'apply-host-settings.sh', 'session-control.sh', - 'patch-host.sh', + 'patch-host.sh', 'xrdp-startwm.sh', ) HEARTBEAT_DESKTOPS = ('gnome', 'xfce', 'mate', 'kde', 'other', 'none', 'unknown') HEARTBEAT_SESSION_STATES = ('active', 'disconnected', 'unknown') diff --git a/api/config.py b/api/config.py index 097abfc..e4ea3bb 100644 --- a/api/config.py +++ b/api/config.py @@ -204,7 +204,7 @@ def env_int(name, default, minimum=None, maximum=None): # with any change to those scripts; api/tests checks they agree. Fleet health flags a host # whose reported agent or scripts are older. The override exists so an operator can silence # the flag during a staged rollout. -HOST_AGENT_VERSION = '1.1.0' +HOST_AGENT_VERSION = '1.2.0' EXPECTED_HOST_AGENT_VERSION = (os.environ.get('EXPECTED_HOST_AGENT_VERSION') or '').strip() or HOST_AGENT_VERSION HEARTBEAT_MAX_BYTES = 32 * 1024 diff --git a/api/tests/test_host_list.py b/api/tests/test_host_list.py index 9b0c86b..d197974 100644 --- a/api/tests/test_host_list.py +++ b/api/tests/test_host_list.py @@ -10,7 +10,7 @@ def page_row(hostname, **values): row = {"VMID": 1, "Hostname": hostname, "IPAddress": "10.0.0.4", "PowerState": "On", "NetworkStatus": "Reachable", "VmStatus": "CheckedOut", "Username": "alice", "Ready": False, "CleanupPending": False, "DrainRequested": False, - "SettingsVersion": 3, "CurrentSettingsVersion": 3, "AgentVersion": "1.1.0", "HeartbeatAgeSeconds": 20, + "SettingsVersion": 3, "CurrentSettingsVersion": 3, "AgentVersion": "1.2.0", "HeartbeatAgeSeconds": 20, "ReconcileIntervalSeconds": 60, "SessionsJson": json.dumps([{"username": "Alice", "state": "disconnected"}]), "TotalCount": 41} row.update(values) diff --git a/api/tests/test_phase2_foundations.py b/api/tests/test_phase2_foundations.py index 1159b3e..e95e315 100644 --- a/api/tests/test_phase2_foundations.py +++ b/api/tests/test_phase2_foundations.py @@ -731,8 +731,8 @@ def health_row(hostname, **values): "VMID": 1, "Hostname": hostname, "PowerState": "On", "NetworkStatus": "Reachable", "VmStatus": "Available", "DrainRequested": False, "CleanupPending": False, "Username": None, "AppliedSettingsVersion": 7, "CurrentSettingsVersion": 7, "ReconcileIntervalSeconds": 60, "LastHeartbeatUtc": "2026-09-24T12:00:00Z", - "HeartbeatAgeSeconds": 30, "AgentVersion": "1.1.0", - "ScriptVersionsJson": '{"release-session.sh": "1.1.0", "create-user.sh": "1.1.0"}', + "HeartbeatAgeSeconds": 30, "AgentVersion": "1.2.0", + "ScriptVersionsJson": '{"release-session.sh": "1.2.0", "create-user.sh": "1.2.0"}', "ReportedSettingsVersion": 7, "OsId": "rhel", "OsVersion": "9.4", "OsName": "RHEL 9.4", "KernelVersion": "5.14", "Desktop": "gnome", "XrdpVersion": "0.10.1", "XrdpActive": True, "NfsReachable": True, "NfsMountCount": 1, "LoadAverage": 0.5, "CpuCount": 4, "MemoryAvailableMb": 8000, "MemoryTotalMb": 16000, "RootDiskFreePct": 60, @@ -750,7 +750,7 @@ def test_fleet_health_flags_what_an_operator_must_act_on(client, fake_db): health_row("off", PowerState="Off", HeartbeatAgeSeconds=9000, AppliedSettingsVersion=3), health_row("broken", XrdpActive=False, NfsReachable=False, RootDiskFreePct=4), health_row("old", AgentVersion="0.9.0"), - health_row("half", ScriptVersionsJson='{"release-session.sh": "1.1.0", "manage-lease.sh": null}'), + health_row("half", ScriptVersionsJson='{"release-session.sh": "1.2.0", "manage-lease.sh": null}'), health_row("drift", AppliedSettingsVersion=None), ] @@ -776,8 +776,8 @@ def test_fleet_health_flags_what_an_operator_must_act_on(client, fake_db): } healthy = body["Hosts"][0] assert healthy["Sessions"] == [{"username": "alice", "state": "active"}] - assert healthy["ScriptVersions"]["create-user.sh"] == "1.1.0" - assert body["ExpectedAgentVersion"] == "1.1.0" and body["StaleAfterSeconds"] == 180 + assert healthy["ScriptVersions"]["create-user.sh"] == "1.2.0" + assert body["ExpectedAgentVersion"] == "1.2.0" and body["StaleAfterSeconds"] == 180 def test_fleet_health_for_one_host(client, fake_db): diff --git a/api/tests/test_trends.py b/api/tests/test_trends.py index 9ca2932..3aab1c6 100644 --- a/api/tests/test_trends.py +++ b/api/tests/test_trends.py @@ -178,7 +178,7 @@ def test_utilization_before_the_database_upgrade_answers_404(client, fake_db): def health_row(hostname, **values): row = {"VMID": 1, "Hostname": hostname, "PowerState": "On", "NetworkStatus": "Reachable", "VmStatus": "Available", - "HeartbeatAgeSeconds": 20, "ReconcileIntervalSeconds": 60, "AgentVersion": "1.1.0", "ScriptVersionsJson": None, + "HeartbeatAgeSeconds": 20, "ReconcileIntervalSeconds": 60, "AgentVersion": "1.2.0", "ScriptVersionsJson": None, "XrdpActive": True, "NfsReachable": True, "RootDiskFreePct": 50, "CurrentSettingsVersion": 3, "AppliedSettingsVersion": 3} row.update(values) diff --git a/api/tests_integration/test_trends_against_sql.py b/api/tests_integration/test_trends_against_sql.py index 16a3eb4..3f8c95e 100644 --- a/api/tests_integration/test_trends_against_sql.py +++ b/api/tests_integration/test_trends_against_sql.py @@ -66,7 +66,7 @@ def test_attention_combines_broker_items_with_host_health(client, db): db.run("UPDATE dbo.VirtualMachines SET PowerStateChangedDate = DATEADD(MINUTE, -30, GETDATE()) WHERE Hostname = 'lnxhost-02'") db.run("UPDATE dbo.VirtualMachines SET SettingsVersion = (SELECT TOP 1 SettingsVersion FROM dbo.LinuxHostSettings)") heartbeat = client.post("/api/hosts/lnxhost-01/heartbeat", data=json.dumps({ - "agentVersion": "1.1.0", "xrdp": {"active": False}, "sessions": [{"username": "dave", "state": "active"}], + "agentVersion": "1.2.0", "xrdp": {"active": False}, "sessions": [{"username": "dave", "state": "active"}], }), content_type="application/json") assert heartbeat.status_code == 200, heartbeat.get_json() diff --git a/linux_host/apply-host-settings.sh b/linux_host/apply-host-settings.sh index 0e58776..e8545df 100644 --- a/linux_host/apply-host-settings.sh +++ b/linux_host/apply-host-settings.sh @@ -23,7 +23,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" LOG_FILE="/var/log/linuxbroker-host-settings.log" SETTINGS_DIRECTORY="/etc/linuxbroker" diff --git a/linux_host/create-user.sh b/linux_host/create-user.sh index 79201b7..6b1db3d 100644 --- a/linux_host/create-user.sh +++ b/linux_host/create-user.sh @@ -5,7 +5,7 @@ # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" # Constants NFS_MOUNT_ROOT="/awipsprofiles" diff --git a/linux_host/manage-lease.sh b/linux_host/manage-lease.sh index 9965963..1f155cf 100644 --- a/linux_host/manage-lease.sh +++ b/linux_host/manage-lease.sh @@ -14,7 +14,7 @@ set -u # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" LEASE_DIRECTORY="/var/lib/linuxbroker-release-session/leases" # Where create-user.sh leaves the key that opens each user's login keyring. diff --git a/linux_host/patch-host.sh b/linux_host/patch-host.sh index 72d601c..c9a282b 100644 --- a/linux_host/patch-host.sh +++ b/linux_host/patch-host.sh @@ -26,7 +26,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" STATE_DIRECTORY="/var/lib/linuxbroker-release-session" STATE_FILE="$STATE_DIRECTORY/patch-state" diff --git a/linux_host/session-control.sh b/linux_host/session-control.sh index 5104e8c..6114101 100644 --- a/linux_host/session-control.sh +++ b/linux_host/session-control.sh @@ -18,7 +18,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" LEASE_DIRECTORY="/var/lib/linuxbroker-release-session/leases" HOME_ROOT="/home" diff --git a/linux_host/session_release_buffer/logind-session-watcher.sh b/linux_host/session_release_buffer/logind-session-watcher.sh index d29b52e..d651ffa 100644 --- a/linux_host/session_release_buffer/logind-session-watcher.sh +++ b/linux_host/session_release_buffer/logind-session-watcher.sh @@ -4,7 +4,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" WATCHER_LOG_FILE="/var/log/release-session-watcher.log" STATE_DIRECTORY="/var/lib/linuxbroker-release-session" diff --git a/linux_host/session_release_buffer/release-session.sh b/linux_host/session_release_buffer/release-session.sh index 18f8727..ebed7a9 100644 --- a/linux_host/session_release_buffer/release-session.sh +++ b/linux_host/session_release_buffer/release-session.sh @@ -7,7 +7,7 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" LOG_FILE="/var/log/release-session.log" LOCATION_PATH="/usr/local/bin" @@ -738,7 +738,7 @@ check_unmount_user_homes() { # reconciliation must never depend on it. # --------------------------------------------------------------------------- -HEARTBEAT_SCRIPTS=(release-session.sh logind-session-watcher.sh xrdp-who-xorg.sh create-user.sh manage-lease.sh apply-host-settings.sh session-control.sh patch-host.sh) +HEARTBEAT_SCRIPTS=(release-session.sh logind-session-watcher.sh xrdp-who-xorg.sh create-user.sh manage-lease.sh apply-host-settings.sh session-control.sh patch-host.sh xrdp-startwm.sh) HEARTBEAT_BACKOFF_SECONDS=900 # The version an installed script declares, so a host that was only partly migrated shows up. diff --git a/linux_host/session_release_buffer/xrdp-who-xorg.sh b/linux_host/session_release_buffer/xrdp-who-xorg.sh index 6fd4f82..e0a658b 100644 --- a/linux_host/session_release_buffer/xrdp-who-xorg.sh +++ b/linux_host/session_release_buffer/xrdp-who-xorg.sh @@ -5,7 +5,7 @@ # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" if [ -t 1 ] && [ -n "$TERM" ]; then RED=$(tput setaf 1; tput bold) #"\033[1;31m" diff --git a/linux_host/tests/test_heartbeat.sh b/linux_host/tests/test_heartbeat.sh index 7db6228..87e040c 100644 --- a/linux_host/tests/test_heartbeat.sh +++ b/linux_host/tests/test_heartbeat.sh @@ -69,9 +69,10 @@ heartbeat_for_script() { mkdir -p "$STATE_DIRECTORY" "$bin" : > "$LOG_FILE" - [ "$LINUXBROKER_AGENT_VERSION" = "1.1.0" ] || fail "$label declares agent version $LINUXBROKER_AGENT_VERSION" + [ "$LINUXBROKER_AGENT_VERSION" = "1.2.0" ] || fail "$label declares agent version $LINUXBROKER_AGENT_VERSION" [[ " ${HEARTBEAT_SCRIPTS[*]} " == *" session-control.sh "* ]] || fail "$label does not report session-control.sh" [[ " ${HEARTBEAT_SCRIPTS[*]} " == *" patch-host.sh "* ]] || fail "$label does not report patch-host.sh" + [[ " ${HEARTBEAT_SCRIPTS[*]} " == *" xrdp-startwm.sh "* ]] || fail "$label does not report xrdp-startwm.sh" # One script is current and one predates the version constant. printf '#!/bin/bash\nLINUXBROKER_AGENT_VERSION="1.0.0"\n' > "$bin/release-session.sh" @@ -84,7 +85,7 @@ heartbeat_for_script() { assert_json "$session" '(.sessionStart | type) == "number" and .idleSeconds == null' "$label session times" payload=$(build_heartbeat "[$session]") - assert_json "$payload" '.agentVersion == "1.1.0" and .settingsVersion == 7' "$label versions" + assert_json "$payload" '.agentVersion == "1.2.0" and .settingsVersion == 7' "$label versions" assert_json "$payload" '.scriptVersions["manage-lease.sh"] == null and .scriptVersions["release-session.sh"] == "1.0.0"' "$label scripts" assert_json "$payload" '(.os.id | type) == "string" and (.kernel | type) == "string"' "$label os" assert_json "$payload" '.desktop == "none" and .xrdp.version == null and .xrdp.active == true' "$label desktop and xrdp" diff --git a/linux_host/xrdp-startwm.sh b/linux_host/xrdp-startwm.sh index 5631d1b..0578d7a 100644 --- a/linux_host/xrdp-startwm.sh +++ b/linux_host/xrdp-startwm.sh @@ -19,7 +19,7 @@ # The Linux Broker host agent version. Every script in linux_host/ declares the same value # and the heartbeat reports it; bump them together with HOST_AGENT_VERSION in api/config.py. -LINUXBROKER_AGENT_VERSION="1.1.0" +LINUXBROKER_AGENT_VERSION="1.2.0" LAUNCHER_PATH="/usr/local/bin/xrdp-startwm.sh" SESMAN_INI="/etc/xrdp/sesman.ini" From 6a7e7e6f33c788c20e4fa9baf48c9f748f690602 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 22:27:38 -0400 Subject: [PATCH 14/19] Run the host migration script with bash Run Command starts a script without a shebang with /bin/sh. On Ubuntu that is dash, which stops at set -o pipefail on the first line, so Migrate left every Ubuntu host on its old agent and reported it as failed. RHEL hosts worked only because their /bin/sh is bash. Found migrating the Ubuntu test hosts to agent 1.2.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- deploy/Migrate-LinuxHostReleaseAgent.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/Migrate-LinuxHostReleaseAgent.ps1 b/deploy/Migrate-LinuxHostReleaseAgent.ps1 index bc74be6..189ac0c 100644 --- a/deploy/Migrate-LinuxHostReleaseAgent.ps1 +++ b/deploy/Migrate-LinuxHostReleaseAgent.ps1 @@ -196,7 +196,10 @@ if (-not $linuxHosts) { exit 0 } +# Run Command starts a script without a shebang with /bin/sh, which on Ubuntu is dash: it stops +# at the first line, and the host keeps its old agent. $remoteScript = @' +#!/bin/bash set -euo pipefail api_base_url=__API_BASE_URL__ From 153f6cc44b09bc7bf55a30e3545885df115c2e98 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 25 Sep 2026 23:31:43 -0400 Subject: [PATCH 15/19] Turn off the Tracker file indexer in broker sessions Round 2 validation found Tracker crawling the NFS homes. Ubuntu enables its miner for every GNOME session, RHEL 8's GNOME and RHEL 9's Xfce start it from its autostart entries, and D-Bus starts it on demand everywhere else. Its database lives in the roaming home, and one that Ubuntu's Tracker 3.7 wrote does not open in RHEL 9's Tracker 3.1: the miner there failed with "SQL logic error" and systemd restarted it every 13 seconds, reading about 0.6 MB/s from the share. xrdp-startwm.sh --install now masks the Tracker and LocalSearch user services in /etc/systemd/user, which also stops D-Bus from starting them, and writes Hidden=true copies of their autostart entries to /etc/linuxbroker/xdg/autostart. The launcher puts that directory first in XDG_CONFIG_DIRS for every session. No distribution file changes. The bootstrap, the host migration and every patch run already call --install. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- deploy/DEPLOYMENT.md | 1 + docs/ROADMAP.md | 3 +- linux_host/tests/test_xrdp_startwm.sh | 147 +++++++++++++++++++++++++- linux_host/xrdp-startwm.sh | 101 +++++++++++++++++- 5 files changed, 249 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 882f7ee..132b12c 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ On RHEL, Rocky Linux and AlmaLinux, Xfce and MATE come from EPEL. Rocky Linux an These scripts: - **Install xrdp**: Set up xrdp for full desktop access over RDP, enabling users to connect via AVD. The host firewall allows only SSH and RDP. -- **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the desktop the deployment chose. +- **Start the desktop**: xrdp starts every session through `xrdp-startwm.sh`, which runs the desktop the deployment chose. GNOME's file indexer is turned off, because it would crawl the home directories on the NFS share. - **Configure Authentication**: Sets up authentication mechanisms for secure user access. - **Deploy the Linux Session Release Agent**: Installs the timer-based reconciliation service plus a `systemd-logind` watcher that can trigger early reconciliations. The timer remains the fallback path so the system still converges even if event delivery is delayed or unavailable. - **Install the Host Settings Agent**: Installs `apply-host-settings.sh` and seeds the settings profile, so screen lock policy and session timings are applied consistently on every supported distribution rather than only on RHEL 8. `LINUXBROKER_DISABLE_SCREEN_LOCK` still chooses the screen lock posture that is seeded; from then on the values are managed from the portal. diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 5f452f3..8184813 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -586,6 +586,7 @@ This release changes which Linux distributions and desktops the deployment offer - **RHEL 7 is no longer offered.** `7-LVM` is removed from `linuxHostOsVersion`, along with `Configure-RHEL7-Host.sh`; RHEL 7 left maintenance on June 30, 2024. An azd environment that still stores `linuxHostOsVersion=7-LVM` fails template validation at the next `azd provision`, even with `deployLinuxHosts=false`, so set it to a supported value first. A VM's image cannot be changed in place, so for existing RHEL 7 hosts either also set `deployLinuxHosts=false`, which leaves them as they are, or replace them: drain them, delete the VMs in Azure and their records in the portal, and run `azd provision`. Existing RHEL 7 hosts keep working with the broker, and `patch-host.sh` and the host migration still support them. - **One release agent for every distribution.** The separate RHEL and Ubuntu copies of `release-session.sh` are merged into `linux_host/session_release_buffer/release-session.sh`, and the unused `xrdp-who-xnc.sh` is deleted. Ubuntu hosts now also unmount orphaned NFS homes, as RHEL hosts did. Run [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release: a copy from an earlier release downloads the old paths, which no longer exist, and stops before it changes anything. - **xrdp starts sessions through `xrdp-startwm.sh`.** The bootstrap and the host migration install `/usr/local/bin/xrdp-startwm.sh` and make it the `DefaultWindowManager` in `/etc/xrdp/sesman.ini`. The first change keeps the original file as `sesman.ini.linuxbroker-orig`, the previous value is recorded in `/etc/linuxbroker/xrdp-startwm.conf`, and xrdp-sesman reloads its configuration without ending any session. The launcher starts the desktop named in `/etc/linuxbroker/desktop.conf`, which the bootstrap writes; without that file, as on a migrated host, it runs the distribution's own session script as before. It also adds `/etc/polkit-1/rules.d/45-linuxbroker-xrdp.rules`, so members of `tsusers` are not asked for an administrator's password when their session creates a color profile or refreshes the package lists. Every maintenance patch run installs it again in case an update replaced `sesman.ini`, and security updates on Ubuntu now keep configuration files that were changed locally, as all updates already did. +- **File indexing is off in broker sessions.** Tracker, GNOME's file indexer, keeps its index in each home directory, so on broker hosts it crawled the NFS share. Homes also move between hosts, and an index that one distribution's Tracker wrote does not open in another's: RHEL 9 then restarted its indexer every few seconds, reading the share each time. `xrdp-startwm.sh --install`, which the bootstrap, the host migration and every maintenance patch run call, now masks Tracker's user services with links to `/dev/null` in `/etc/systemd/user`. It also hides Tracker's autostart entries, which Xfce, and GNOME on RHEL 8, start directly: copies marked `Hidden=true` go in `/etc/linuxbroker/xdg/autostart`, which the launcher puts ahead of `/etc/xdg` for every session. The distribution's own files are not changed. Search in the Files app still works, without the index; the Xfce and MATE file managers never used it. Sessions already running on a migrated host keep any indexer they started. Existing indexes stay in each profile, in `~/.cache/tracker3` or, from RHEL 8, `~/.cache/tracker` and `~/.local/share/tracker`, and can be deleted. - **Ubuntu hosts run the Ubuntu desktop.** `24_04-lts` still deploys Canonical's Ubuntu 24.04 server image, and the bootstrap now adds `ubuntu-desktop-minimal`, which xrdp sessions run as Ubuntu on Xorg, so the screen lock and host settings apply to Ubuntu hosts too. The first-login wizard, crash reporting and update notifications are left out, because broker users cannot act on them. Firefox, a snap on Ubuntu, is installed on its own, and a host that cannot reach the Snap Store finishes without it. The bootstrap no longer adds Microsoft's package repository or installs the Azure CLI, and broker users get `/bin/bash` rather than Ubuntu's default `/bin/sh`; existing users are switched at their next sign-in. The previous bootstrap's package install failed on Ubuntu 24.04, because Microsoft's repository has no `azure-cli` package for it, so existing Ubuntu hosts lack `nfs-common`, `jq` and `dconf-cli` and cannot mount NFS homes. Replace them as described for RHEL 7 above, or drain each one and run the new bootstrap on it. Restarting xrdp ends the connections to the host, so it must be drained: ```powershell diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bacd888..68cede8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -780,7 +780,8 @@ generate many small I/Os, especially browser and GNOME caches and indexers. - A sizing guide by concurrent users, and a Bicep default that reflects it. - Keep caches local: `/etc/profile.d/linuxbroker-cache.sh` sets `XDG_CACHE_HOME=/var/tmp/xdg-cache/$USER`, with `systemd-tmpfiles` cleanup. Point - browser disk caches at it. Disable GNOME Tracker/LocalSearch indexing of NFS homes. + browser disk caches at it. Tracker/LocalSearch no longer indexes NFS homes: 3.4 turned + it off. - Mount tuning per Azure Files NFS guidance (`nconnect=4` is already set; consider `read_ahead_kb`). - Alerts on share throttling (`Transactions` with `SuccessWithThrottling`) and diff --git a/linux_host/tests/test_xrdp_startwm.sh b/linux_host/tests/test_xrdp_startwm.sh index 3d734ab..09b8887 100644 --- a/linux_host/tests/test_xrdp_startwm.sh +++ b/linux_host/tests/test_xrdp_startwm.sh @@ -14,6 +14,7 @@ FAKE_SESMAN_PID="" # Everything the tests create. Whatever was there before is set aside and put back. TOUCHED=(/etc/xrdp /usr/libexec/xrdp /etc/polkit-1 /etc/X11 /usr/share/gnome-session /etc/linuxbroker + /usr/lib/systemd/user /etc/systemd/user /etc/xdg/autostart "$LAUNCHER" "$SHIM_DIR/systemctl" "$SHIM_DIR/gnome-session" "$SHIM_DIR/startxfce4" "$SHIM_DIR/mate-session" "$SHIM_DIR/logger" "$SHIM_DIR/gnome-keyring-daemon" "$SHIM_DIR/gdbus" "$SHIM_DIR/pgrep" "$SHIM_DIR/pkill" /run/linuxbroker-keyring) @@ -125,7 +126,7 @@ fake_session_script() { echo "ran=$label" echo "args=\$*" for name in DESKTOP_SESSION XDG_SESSION_DESKTOP XDG_CURRENT_DESKTOP XDG_SESSION_TYPE GNOME_SHELL_SESSION_MODE LBTEST_PROFILE \ - GNOME_KEYRING_CONTROL SSH_AUTH_SOCK; do + GNOME_KEYRING_CONTROL SSH_AUTH_SOCK XDG_CONFIG_DIRS; do echo "\$name=\${!name:-}" done } > "\${LBTEST_SESSION_OUT:-/dev/null}" @@ -238,10 +239,124 @@ INI assert_file_contains "$SESMAN_INI" "DefaultWindowManager=/usr/local/bin/xrdp-startwm.sh" assert_contains "$out" "polkit is not installed" assert_not_exists /etc/polkit-1 + assert_contains "$out" "No file indexer is installed." + assert_not_exists /etc/systemd/user + assert_not_exists /etc/linuxbroker/xdg # Without a running xrdp-sesman there is nothing to reload. assert_file_contains "$FAKE_CALLS" "systemctl show --property MainPID --value xrdp-sesman.service" } +# Stand-ins for an indexer's packaged user services and autostart entries. +fake_user_units() { + local unit + mkdir -p /usr/lib/systemd/user + for unit in "$@"; do + printf '[Service]\nExecStart=/usr/libexec/%s\n' "${unit%.service}" > "/usr/lib/systemd/user/$unit" + done +} + +fake_autostart_entries() { + local entry + mkdir -p /etc/xdg/autostart + for entry in "$@"; do + printf '[Desktop Entry]\nType=Application\nExec=/usr/libexec/%s\nOnlyShowIn=GNOME;KDE;XFCE;\n' "${entry%.desktop}" \ + > "/etc/xdg/autostart/$entry" + done +} + +assert_masked() { + [ -L "/etc/systemd/user/$1" ] || fail "expected /etc/systemd/user/$1 to be a mask${2:+ ($2)}" + assert_eq "$(readlink "/etc/systemd/user/$1")" "/dev/null" "$1${2:+ ($2)}" +} + +assert_hidden() { + local entry="/etc/linuxbroker/xdg/autostart/$1" + assert_file_contains "$entry" "Hidden=true" + assert_eq "$(grep -v '^#' "$entry" | head -n 1)" "[Desktop Entry]" "$1 starts with its group" + assert_file_contains "$entry" "Type=Application" + assert_file_contains "$entry" "Name=${1%.desktop}" + assert_eq "$(stat -c %a "$entry")" "644" "$1" +} + +test_install_turns_off_the_file_indexer() { + local out status unit packaged + + # Tracker 3, as Ubuntu 24.04 ships it: the miner is enabled for GNOME sessions. + setup_case + write_ubuntu_sesman + fake_session_script /etc/xrdp/startwm.sh debian-startwm + fake_user_units tracker-miner-fs-3.service tracker-miner-fs-control-3.service tracker-writeback-3.service \ + tracker-xdg-portal-3.service gnome-session-manager@.service + mkdir -p /etc/systemd/user/gnome-session.target.wants + ln -s /usr/lib/systemd/user/tracker-miner-fs-3.service /etc/systemd/user/gnome-session.target.wants/tracker-miner-fs-3.service + fake_autostart_entries tracker-miner-fs-3.desktop nm-applet.desktop + packaged=$(md5sum /etc/xdg/autostart/tracker-miner-fs-3.desktop) + + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "install: $out" + for unit in tracker-miner-fs-3.service tracker-miner-fs-control-3.service tracker-writeback-3.service; do + assert_masked "$unit" + done + assert_not_exists /etc/systemd/user/tracker-xdg-portal-3.service + assert_not_exists /etc/systemd/user/gnome-session-manager@.service + assert_hidden tracker-miner-fs-3.desktop + assert_eq "$(ls -A /etc/linuxbroker/xdg/autostart | tr '\n' ' ')" "tracker-miner-fs-3.desktop " "only the indexer's entries" + assert_eq "$(stat -c %a /etc/linuxbroker/xdg)" "755" + assert_eq "$(stat -c %a /etc/linuxbroker/xdg/autostart)" "755" + assert_eq "$(md5sum /etc/xdg/autostart/tracker-miner-fs-3.desktop)" "$packaged" "the package's entry is not changed" + assert_eq "$(readlink /etc/systemd/user/gnome-session.target.wants/tracker-miner-fs-3.service)" \ + "/usr/lib/systemd/user/tracker-miner-fs-3.service" "the package's enablement is not changed" + assert_contains "$out" "The file indexer's services are masked: tracker-miner-fs-3.service tracker-miner-fs-control-3.service tracker-writeback-3.service." + assert_contains "$out" "The file indexer's autostart entries are hidden from broker sessions: tracker-miner-fs-3.desktop." + + # Running it again changes nothing; an edited entry is managed. + echo "Hidden=false" >> /etc/linuxbroker/xdg/autostart/tracker-miner-fs-3.desktop + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "second install: $out" + assert_not_contains_file /etc/linuxbroker/xdg/autostart/tracker-miner-fs-3.desktop "Hidden=false" + assert_masked tracker-miner-fs-3.service "after a second install" + + # A file an administrator put in a mask's place is left alone. + rm -f /etc/systemd/user/tracker-writeback-3.service + printf '[Service]\nExecStart=/usr/local/bin/writeback\n' > /etc/systemd/user/tracker-writeback-3.service + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "install beside an administrator's unit: $out" + assert_contains "$out" "WARNING: /etc/systemd/user/tracker-writeback-3.service is not a mask" + assert_file_contains /etc/systemd/user/tracker-writeback-3.service "ExecStart=/usr/local/bin/writeback" + assert_masked tracker-miner-fs-3.service "beside an administrator's unit" + + # Tracker 2, as RHEL 8 ships it, which its GNOME starts from the autostart entries. + setup_case + write_ubuntu_sesman + fake_session_script /etc/xrdp/startwm.sh debian-startwm + fake_user_units tracker-store.service tracker-miner-fs.service tracker-miner-apps.service tracker-extract.service \ + tracker-writeback.service + fake_autostart_entries tracker-store.desktop tracker-miner-fs.desktop tracker-miner-apps.desktop tracker-extract.desktop + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "Tracker 2: $out" + for unit in tracker-store.service tracker-miner-fs.service tracker-miner-apps.service tracker-extract.service \ + tracker-writeback.service; do + assert_masked "$unit" "Tracker 2" + done + assert_eq "$(stat -c %a /etc/systemd/user)" "755" + for unit in tracker-store.desktop tracker-miner-fs.desktop tracker-miner-apps.desktop tracker-extract.desktop; do + assert_hidden "$unit" + done + + # LocalSearch, as GNOME 47 renamed it. + setup_case + write_ubuntu_sesman + fake_session_script /etc/xrdp/startwm.sh debian-startwm + fake_user_units localsearch-3.service localsearch-control-3.service tinysparql-xdg-portal-3.service + fake_autostart_entries localsearch-3.desktop + out=$(bash "$LAUNCHER" --install 2>&1); status=$? + assert_eq "$status" "0" "LocalSearch: $out" + assert_masked localsearch-3.service "LocalSearch" + assert_masked localsearch-control-3.service "LocalSearch" + assert_not_exists /etc/systemd/user/tinysparql-xdg-portal-3.service + assert_hidden localsearch-3.desktop +} + test_install_reads_sesman_ini_as_xrdp_does() { local out status @@ -507,6 +622,34 @@ test_xfce_and_mate_on_rhel() { assert_eq "$(session_value ran)" "rhel-startwm" "no xinit Xsession" } +test_sessions_skip_the_hidden_autostart_entries() { + setup_case + setup_rhel_session + install_desktop_shim startxfce4 + printf 'DESKTOP=xfce\n' > "$DESKTOP_FILE" + + # Nothing hidden, nothing changed. + run_session + assert_eq "$(session_value XDG_CONFIG_DIRS)" "" + + mkdir -p /etc/linuxbroker/xdg/autostart + run_session + assert_eq "$(session_value ran)" "rhel-xsession" + assert_eq "$(session_value XDG_CONFIG_DIRS)" "/etc/linuxbroker/xdg:/etc/xdg" + + run_session XDG_CONFIG_DIRS=/etc/xdg/xdg-custom:/etc/xdg + assert_eq "$(session_value XDG_CONFIG_DIRS)" "/etc/linuxbroker/xdg:/etc/xdg/xdg-custom:/etc/xdg" + + run_session XDG_CONFIG_DIRS=/etc/linuxbroker/xdg:/etc/xdg + assert_eq "$(session_value XDG_CONFIG_DIRS)" "/etc/linuxbroker/xdg:/etc/xdg" "already first" + + # The distribution's own script gets them too. + printf 'DESKTOP=gnome\n' > "$DESKTOP_FILE" + run_session + assert_eq "$(session_value ran)" "rhel-startwm" + assert_eq "$(session_value XDG_CONFIG_DIRS)" "/etc/linuxbroker/xdg:/etc/xdg" +} + test_an_unusable_record_falls_back() { local record setup_case @@ -726,6 +869,7 @@ test_without_a_usable_key_the_desktop_starts_as_before() { test_install_on_ubuntu test_install_on_rhel +test_install_turns_off_the_file_indexer test_install_reads_sesman_ini_as_xrdp_does test_install_refusals test_ubuntu_on_xorg @@ -733,6 +877,7 @@ test_otherwise_the_distribution_script_runs test_xfce_and_mate_on_debian test_rhel_runs_its_own_script_for_gnome test_xfce_and_mate_on_rhel +test_sessions_skip_the_hidden_autostart_entries test_an_unusable_record_falls_back test_the_login_keyring_opens_with_the_brokers_key test_a_keyring_the_key_cannot_open_is_moved_aside diff --git a/linux_host/xrdp-startwm.sh b/linux_host/xrdp-startwm.sh index 0578d7a..287a6b3 100644 --- a/linux_host/xrdp-startwm.sh +++ b/linux_host/xrdp-startwm.sh @@ -8,12 +8,14 @@ # script cannot start Ubuntu's session on Xorg, or choose between desktops installed side by # side. Anything this script does not handle, including a host without desktop.conf, runs the # distribution's script exactly as before. First, it unlocks the user's login keyring with the -# key create-user.sh left for them, when there is one. +# key create-user.sh left for them, when there is one, and puts /etc/linuxbroker/xdg ahead of +# the distribution's configuration directories. # # --install, as root, points DefaultWindowManager in /etc/xrdp/sesman.ini at this script. It # records the script it replaces in /etc/linuxbroker/xrdp-startwm.conf, keeps the original # file as sesman.ini.linuxbroker-orig, and installs a polkit rule so broker users are not asked -# for an administrator's password inside an xrdp session. It is idempotent: the host migration +# for an administrator's password inside an xrdp session. It also turns off the file indexer, +# which would crawl the home directories on the NFS share. It is idempotent: the host migration # runs it, and patch-host.sh runs it after every patch run in case an update replaced # sesman.ini. It exits 3 when xrdp is not installed, and 1 on any other failure. @@ -31,6 +33,10 @@ DESKTOP_FILE="$SETTINGS_DIRECTORY/desktop.conf" POLKIT_RULES_DIRECTORY="/etc/polkit-1/rules.d" POLKIT_RULE_FILE="$POLKIT_RULES_DIRECTORY/45-linuxbroker-xrdp.rules" UBUNTU_SESSION_FILE="/usr/share/gnome-session/sessions/ubuntu.session" +USER_UNIT_DIRECTORY="/usr/lib/systemd/user" +USER_UNIT_MASK_DIRECTORY="/etc/systemd/user" +AUTOSTART_DIRECTORY="/etc/xdg/autostart" +XDG_OVERRIDE_DIRECTORY="$SETTINGS_DIRECTORY/xdg" # Where a relative DefaultWindowManager lives: /etc/xrdp upstream, /usr/libexec/xrdp in the # Fedora and EPEL packages. @@ -240,6 +246,85 @@ install_polkit_rule() { write_managed_file "$POLKIT_RULE_FILE" "$(polkit_rule)" } +# Tracker, which GNOME 47 renames LocalSearch, indexes each home directory into a database it +# keeps in that home, so on a broker host it crawls the NFS share. Homes also move between +# hosts, and a database one distribution's Tracker wrote does not open in another's: RHEL 9's +# miner then exits, and systemd restarts it every few seconds, reading the share each time. Its +# services are masked, which also stops D-Bus from starting them, and its autostart entries are +# hidden from the sessions this script starts, because Xfce, and GNOME on RHEL 8, run them +# directly. File managers still search, without the index. A session that is already running +# keeps any indexer it started. + +# The indexer's user services, except its portal, which only passes on sandboxed applications' +# queries. +indexer_units() { + local path name + + for path in "$USER_UNIT_DIRECTORY"/tracker-*.service "$USER_UNIT_DIRECTORY"/localsearch-*.service; do + [ -f "$path" ] || continue + name="${path##*/}" + case "$name" in + *-xdg-portal-*) ;; + *) printf '%s\n' "$name" ;; + esac + done +} + +# Masks a user service for every user, as systemctl --global mask does, unless an +# administrator has put a file of their own in its place. +mask_user_unit() { + local link="$USER_UNIT_MASK_DIRECTORY/$1" + + if [ -L "$link" ] && [ "$(readlink "$link")" = "/dev/null" ]; then + return 0 + fi + if [ -e "$link" ] || [ -L "$link" ]; then + echo "WARNING: $link is not a mask, so $1 is left as it is." + return 0 + fi + mkdir -p "$USER_UNIT_MASK_DIRECTORY" && ln -s /dev/null "$link" && restore_context "$link" +} + +hidden_autostart_entry() { + cat <