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/README.md b/README.md index 107de69..b8b323f 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. @@ -37,7 +37,7 @@ The solution consists of the following components: - **Service Management Portal**: A front-end web application that allows administrators to manage VMs, scaling rules, and monitor the system. It provides functionalities such as finding and acting on hosts in bulk, importing hosts from Azure, helping users with their sessions, scheduling scaling, patching hosts in rolling maintenance runs, charting capacity and unmet demand, and viewing logs. It is a React 18 and TypeScript single-page app built with Vite and Tailwind CSS, served by a Flask backend-for-frontend that holds the Entra ID token server-side and calls the Broker API on the administrator's behalf. -- **Azure Key Vault**: Stores sensitive information such as SSH keys and database passwords, accessed securely by the Broker API using managed identity. +- **Azure Key Vault**: Stores sensitive information such as SSH keys and database passwords, accessed securely by the Broker API using managed identity. A second vault holds the key that unlocks each user's login keyring. - **Managed Identities and Security Groups**: Used throughout the solution to securely authenticate and authorize different components. AVD hosts and Linux hosts have managed identities and are members of respective security groups. @@ -55,7 +55,7 @@ The architecture ensures secure, efficient, and scalable management of Linux hos - **Broker Database**: Azure SQL Database for storing VM and scaling data. - **Azure Function for Scaling Tasks**: Manages scaling of Linux hosts. - **Service Management Portal**: React and TypeScript front-end application for administrators, served by a Flask backend-for-frontend. -- **Azure Key Vault**: Secure storage for SSH keys and passwords. +- **Azure Key Vault**: Secure storage for SSH keys, passwords and each user's login keyring key. - **Managed Identities**: Used for secure authentication between components. - **Security Groups**: Controls access permissions for managed identities. @@ -67,8 +67,9 @@ 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. + - The host also receives the key that unlocks the user's login keyring, so applications that save passwords do not ask for one. +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. @@ -185,12 +186,22 @@ 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** -- **Ubuntu 24 Desktop** +- **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 its rebuilds, and on Ubuntu the Ubuntu desktop, which xrdp sessions run as Ubuntu on Xorg +- **Xfce** +- **MATE** + +On RHEL, Rocky Linux and AlmaLinux, Xfce and MATE come from EPEL. Rocky Linux and AlmaLinux install EPEL from their own repositories. 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 unlocks the user's login keyring and 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. @@ -217,7 +228,7 @@ Every run first reads each host's power state from Azure and corrects the broker - **Session Monitoring**: The Session Release Agent reconciles XRDP/Xorg session state on a timer (60 seconds by default) and can also wake early from `systemd-logind` session signals. - **Release State**: When a session is disconnected, the VM enters a 'released' state, allowing the user to reconnect within the configured grace period (20 minutes by default). The desktop itself is closed at disconnect unless **Keep sessions alive during the grace period** is turned on, in which case it keeps running until the grace period expires. - **Session Termination**: If the user does not reconnect within that window, the host signs them off. The broker returns the VM once the grace period, one reconcile interval and a further 60 seconds have passed, then keeps it **Cleanup pending** until the user's account has been removed and the home unmounted. Cleanup is retried automatically about every two minutes while the host is on and reachable, and operators can retry it from the portal. Only then can the VM be checked out by someone else. -- **Idle Sessions**: When an idle timeout is configured, a user who stays connected but inactive is disconnected, which starts the same grace period. With **Keep sessions alive** turned on they can reconnect and resume; otherwise they get a fresh desktop on the same host. If they do not reconnect, the VM is reclaimed. This is disabled by default. +- **Idle Sessions**: When an idle timeout is configured, a user who stays connected but inactive is disconnected, which starts the same grace period. With **Keep sessions alive** turned on they can reconnect and resume; otherwise they get a fresh desktop on the same host. If they do not reconnect, the VM is reclaimed. Idle time never counts from before the user's latest connection, so a user who reconnects is not disconnected again straight away. This is disabled by default. The agent reads idle time with `xprintidle`, which only Ubuntu packages, so RHEL, Rocky Linux and AlmaLinux hosts do not enforce the timeout or show its warning. ### Linux Host Settings @@ -230,18 +241,20 @@ Administrators manage host behavior from the **Host Settings** page in the Servi | Reconcile interval | 60 s | 30–900 | How often each host re-checks session state | | Watcher debounce | 10 s | 1–300 | Minimum gap between `logind`-triggered reconciliations | | 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 timeout | 0 (disabled) | 0, or 300–86400 | Inactivity before a connected user is disconnected. Only Ubuntu hosts enforce it; see **Idle Sessions** above | | 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 @@ -259,6 +272,7 @@ Because the profile is versioned, the portal shows which hosts have applied the - **Managed Identities**: Used for secure authentication between Azure resources without storing credentials. - **Azure Key Vault**: Stores SSH keys and database passwords securely, accessed via managed identities. +- **Login keyring**: The Linux password changes at every checkout, so it cannot protect a user's GNOME login keyring. The broker keeps a separate random key for each user in a vault of its own, where the API can write secrets but cannot change the deployment's, and `xrdp-startwm.sh` unlocks the keyring with it before the desktop starts. The key reaches the host over the checkout's SSH session and stays in memory-backed storage under `/run`, readable only by the user, until the host is returned. - **API Permissions**: Specific API permissions are granted to components to restrict access based on roles. - **Logging and Monitoring**: All activities are logged to Azure Application Insights and Log Analytics Workspace. @@ -339,9 +353,17 @@ The admin console foundations (audit log, host actions and drain, fleet health) The rest of the admin console (sessions, broadcast messages, scaling schedules, trends, rolling maintenance and the new host list) needs no new Azure resources or roles either, but the Linux hosts need agent 1.1.0 for sign-out, messages, profile resets and patching. See [Upgrading To The Complete Admin Console](deploy/DEPLOYMENT.md#upgrading-to-the-complete-admin-console). +The distribution and desktop support release needs `azd provision` and agent 1.2.0. See [Upgrading To Distribution And Desktop Support](deploy/DEPLOYMENT.md#upgrading-to-distribution-and-desktop-support). + +- **RHEL 7 is no longer offered.** An azd environment that still stores `linuxHostOsVersion=7-LVM` fails validation, so change it before you provision. +- **Run `azd provision`.** It creates the keyring vault, gives the API access to it and sets `KEYRING_VAULT_URL`. The existing hosts keep their image and extension, unless you change `linuxHostDesktop`. +- **Update the Linux hosts to agent 1.2.0.** Fleet health flags every host as outdated until `deploy/Migrate-LinuxHostReleaseAgent.ps1` from this release has updated it. The migration also removes xpra and closes TCP 443. +- **Review the idle timeout.** It had never disconnected anyone before this release, and migrated Ubuntu hosts now enforce any timeout already set. +- **Replace or bootstrap again any Ubuntu hosts.** Earlier releases deployed them with no desktop and without the packages NFS homes need. + ## 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 and Ubuntu 26.04 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/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..a8cfeb3 100644 --- a/api/app.py +++ b/api/app.py @@ -44,7 +44,7 @@ # Flask App app = Flask(__name__) -app.config['VERSION'] = '0.170' +app.config['VERSION'] = '0.171' # Backs is_member_of_group_cached, which keeps token validation off the Graph API on # every request. @@ -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. @@ -4333,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 4b2c748..e4ea3bb 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') @@ -134,7 +137,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. @@ -201,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/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_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_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/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/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-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/custom_script_extensions/Configure-RHEL8-Host.sh b/custom_script_extensions/Configure-RHEL8-Host.sh index b4d6130..434dca6 100644 --- a/custom_script_extensions/Configure-RHEL8-Host.sh +++ b/custom_script_extensions/Configure-RHEL8-Host.sh @@ -5,50 +5,29 @@ 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/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" @@ -61,13 +40,13 @@ 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_ini="/etc/xrdp/xrdp.ini" +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 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 +# 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. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') @@ -81,11 +60,25 @@ 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:-}" 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" @@ -106,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" @@ -117,36 +112,46 @@ 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 # 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 "$remoteAccessTool" in - "xrdp") - remoteAccessPackages=("xrdp") +case "$desktop" in + gnome) + echo "Installing 'Server with GUI' group..." + sudo dnf groupinstall -y "Server with GUI" ;; - "xpra") - remoteAccessPackages=("xpra") + 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 ;; - "both") - remoteAccessPackages=("xrdp" "xpra") - ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 + 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 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 -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 @@ -161,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." @@ -209,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" @@ -302,31 +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" - -# 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. @@ -342,19 +356,18 @@ 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 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 @@ -363,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 7d32724..1fd3423 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:-}" @@ -21,15 +22,13 @@ 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. 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" @@ -42,12 +41,25 @@ 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" -# 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 +# 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. disableScreenLock="${LINUXBROKER_DISABLE_SCREEN_LOCK:-true}" disableScreenLock=$(printf '%s' "$disableScreenLock" | tr '[:upper:]' '[:lower:]') @@ -61,11 +73,26 @@ 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:-}" 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" @@ -88,7 +115,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" @@ -99,59 +128,64 @@ 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" -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 - +# 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. 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 "$remoteAccessTool" in - "xrdp"|"xpra"|"both") +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 ;; - *) - echo "Unsupported remote access tool: $remoteAccessTool" - exit 1 + 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 -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 @@ -166,42 +200,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..." @@ -241,6 +250,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 +262,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. 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" @@ -362,16 +392,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 8d1998a..eca0d07 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 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:-}" @@ -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 @@ -24,7 +31,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" @@ -37,12 +44,41 @@ 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" + +# 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}" +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 -arch=$(uname -m) -remoteAccessTool="both" # Options: "xrdp", "xpra", or "both" +# 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" +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 +96,235 @@ 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 +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. 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 -# Add Xpra repository -echo "Adding Xpra repository..." -sudo add-apt-repository ppa:xpra/stable -y -sudo apt update -y +# 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 -# Install Azure CLI -echo "Installing Azure CLI..." -sudo apt install -y azure-cli nfs-common jq dconf-cli +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[@]}" # 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." - -# Optional: Install Desktop Environment (Uncomment if needed) -# echo "Installing Desktop Environment..." -# sudo apt install -y xfce4 xfce4-goodies # Lightweight desktop environment +apt_get -y install xprintidle || echo "xprintidle is unavailable. Idle session enforcement will be skipped on this host." -# 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 +# 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 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 desktop..." +mkdir -p "$(dirname "$desktop_file")" +chmod 755 "$(dirname "$desktop_file")" +cat > "$desktop_file" </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 +403,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 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 screen saver and screen lock disabled..." + settings_seed='{"ScreenLockEnabled":false,"DisableLockScreen":true}' +else + echo "Seeding host settings with the 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 b24db86..41841b3 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -60,7 +60,7 @@ At a high level, the deployment provisions and configures the following: - Azure Container Registry for the `frontend`, `api`, and `task` images. - App Service apps for the frontend and API, plus a Function App for scheduled work. - Azure SQL Database and firewall rules. -- Azure Key Vault. +- Two Azure Key Vaults: one for the deployment's secrets, and one for the keys that unlock each user's login keyring. - App Service plan, storage account, Application Insights, Log Analytics, and networking. - A private DNS zone, `linuxbroker.internal`, linked to the virtual network with auto-registration, so the broker reaches each Linux host as `.linuxbroker.internal`. It is skipped when you supply `domainName`. - A premium Azure Files NFS share for Linux home directories, reachable only through a private endpoint. It is skipped when you supply `nfsShare`, set `deployNfsShare` to `false`, or deploy no Linux hosts. @@ -76,6 +76,7 @@ The deployment model now follows these runtime rules: - VM managed identities do not get direct API role assignments. - Instead, VM managed identities are added to Entra security groups, and those groups hold the `AvdHost` and `LinuxHost` API app roles. - Key Vault stores only two deployment secrets: `db-password` and `linux-host`. +- The keyring vault holds one secret per user, `keyring-`, which the API creates at the user's first checkout. The template adds none. - Frontend and API auth secrets are stored in app settings, not in Key Vault. - Linux hosts are registered into SQL during `postprovision`. AVD hosts are not. - The API and function apps are integrated with the virtual network's app subnet, so the API reaches Linux hosts on their private IP addresses for SSH and the portal's connectivity test. @@ -100,8 +101,9 @@ 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. -- `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. `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. - `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,30 +239,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, so they 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 +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. +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 @@ -276,15 +293,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-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`, +`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 @@ -292,14 +306,24 @@ not install a desktop environment, so there is no GNOME screen lock to disable. 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 @@ -348,6 +372,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. @@ -366,11 +391,12 @@ 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. -- 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 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. +- A second vault, `kr`, holds the key that unlocks each user's login keyring. The API holds Key Vault Secrets Officer on that vault only, so it can create and rotate the keys without being able to change the deployment's secrets, and finds it through the `KEYRING_VAULT_URL` app setting. Like the main vault, it uses Azure RBAC, allows public network access, and keeps deleted secrets for 90 days. ## What Happens During `postprovision` @@ -458,7 +484,7 @@ The migration also rewrites `/etc/sudoers.d/avdadmin`. Older hosts were provisio `apply-host-settings.sh` is the only way the broker API can change host configuration. It accepts a JSON settings document on stdin and nothing on argv, rejects unknown keys, and clamps every value to a supported range before writing anything, so a bad value cannot strand the fleet. -The migration additionally installs `dconf` and, where available, `xprintidle`. `xprintidle` backs the optional idle session timeout; if it cannot be installed the migration still succeeds and idle enforcement is simply skipped on that host. Existing hosts keep any settings profile they already have, and hosts with no profile are seeded with the shipped defaults, which match the values that were previously hardcoded. +The migration additionally installs `dconf` and, where available, `xprintidle`. `xprintidle` backs the optional idle session timeout; if it cannot be installed the migration still succeeds and idle enforcement is simply skipped on that host. RHEL, Rocky Linux and AlmaLinux do not package it, not even in EPEL, so only Ubuntu hosts enforce the idle timeout. Existing hosts keep any settings profile they already have, and hosts with no profile are seeded with the shipped defaults, which match the values that were previously hardcoded. The current host scripts also bring: @@ -553,6 +579,44 @@ 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, and unlocks each user's login keyring (items 3.1–3.4, 3.6 and 3.7 of the [roadmap](../docs/ROADMAP.md)). Unlike the admin console releases, it adds an Azure resource and a role assignment, so it needs `azd provision`, and the Linux hosts need agent 1.2.0. + +- **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. +- **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 + $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" + ``` + +- **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). +- **The login keyring unlocks.** Every checkout sets a new random password, which cannot protect a GNOME login keyring, and xrdp-sesman has no keyring PAM module, so until now applications that save passwords, such as browsers and Visual Studio Code, asked users for a keyring password at every sign-in. The broker now keeps a random key for each user, separate from the password, as the secret `keyring-` in a new Key Vault, `kr`. A checkout reads the key, or creates it the first time, and hands it to the host, where `xrdp-startwm.sh` unlocks the login keyring with it before the desktop starts, or creates the keyring at the first sign-in. This works on every desktop the deployment offers. `azd provision` creates the vault, gives the API **Key Vault Secrets Officer** on that vault only, and sets `KEYRING_VAULT_URL` on the API. [Migrate-ExistingEnvironment.ps1](Migrate-ExistingEnvironment.ps1) provisions no infrastructure, so run `azd provision` first. Until then, and on hosts older than agent 1.2.0, no key is used and keyrings behave as before. A Key Vault error never fails a checkout: the API logs a warning, and that worker sends no key for the next five minutes. + + The first time a key meets a login keyring that something else protects, such as a password the user chose when an application first asked, the launcher moves that keyring to `~/.local/share/linuxbroker/keyring-backup/` and creates a new one. Passwords and tokens saved before the upgrade then have to be entered again. A profile reset writes a new version of the user's secret and keeps the old ones, which open the keyring kept with the old profile. Do not delete keyring secrets to reset a keyring: the vault keeps a deleted secret for 90 days, and until it is recovered or purged the API cannot create that user's key again, so their keyring stays locked. +- **Host agent 1.2.0.** Every script in `linux_host/` declares 1.2.0 and the API expects it, so **Fleet health** flags every host as **Agent outdated** until [Migrate-LinuxHostReleaseAgent.ps1](Migrate-LinuxHostReleaseAgent.ps1) from this release has updated it. 1.2.0 carries the merged release agent, the session launcher and its keyring unlock, the keyring key in `create-user.sh` and `manage-lease.sh`, the xpra cleanup and the idle timeout fixes below, and its heartbeat also reports the launcher's version. The migration restarts only the release agent's own units and reloads xrdp-sesman's configuration, so sessions in progress keep running, and each session uses the launcher from the next time it starts. It now runs as a bash script. Run Command starts a script with no `#!` line with `/bin/sh`, which on Ubuntu is dash, so earlier migrations failed on every Ubuntu host and left it on its old agent. Hosts that are not running are skipped and named at the end; migrate each one with `-LinuxHostNames` once it is started, because until then it keeps its old agent. +- **The idle timeout disconnects.** Before this release the idle timeout never disconnected anyone, on any distribution: the agent showed the warning, then logged `No xrdp connection process was found` every minute and left the session connected. Agent 1.2.0 finds the xrdp connection from the display socket's peer and ends it, which starts the grace period, and it never signals the xrdp daemon, which carries every connection on the host. It also counts idle time from the current connection at most. X keeps counting while a session is disconnected, and a reconnect sends it no input, so otherwise a user who reconnected after an idle disconnect would be disconnected again within a minute. An environment that already set an idle timeout starts enforcing it on each Ubuntu host as the host is migrated, so review **Idle timeout** in Host Settings first. RHEL, Rocky Linux and AlmaLinux do not package `xprintidle`, so their hosts still skip the timeout and log `Could not read idle time` instead. When a grace period ends with **Keep sessions alive** on, the agent also waits up to five seconds for the Xorg it ended to exit, instead of logging `ERROR: Xorg processes remain` for an Xorg that was still exiting. + +### Recommended order + +1. Run `azd env get-value linuxHostOsVersion`. If it returns `7-LVM`, set a supported value, as described above, before anything else. +2. Run `azd provision`. It creates the keyring vault, its role assignment and `KEYRING_VAULT_URL`, and its `postprovision` step rebuilds the images and restarts the apps. With `linuxHostDesktop` left at `gnome`, the Linux hosts' images and extensions do not change, so no bootstrap runs again. +3. Migrate one idle host with `.\Migrate-ExistingEnvironment.ps1 -EnvironmentName -SkipPostProvision -LinuxHostNames `. Confirm that **Fleet health** shows it on 1.2.0, then sign in to it through AVD, open an application that saves a password, and check `sudo journalctl -t linuxbroker-startwm` on the host for `Unlocked the login keyring`. +4. Migrate the remaining hosts with `-SkipPostProvision` and no `-LinuxHostNames`, then confirm no powered-on host is flagged **Agent outdated**. +5. Replace, or bootstrap again, any Ubuntu hosts from earlier releases and any RHEL 7 hosts you are retiring. + +Every layer tolerates the others being one release behind during the rollout, and the database does not change. Hosts on agent 1.1.0 ignore the key the new API sends and keep working, flagged **Agent outdated**. A 1.2.0 host that meets the previous API, or an API without `KEYRING_VAULT_URL`, gets no key and starts the desktop as before. The previous API drops the launcher's version from the heartbeat. + ## Manual Steps After `azd up` ### Admin consent @@ -601,7 +665,7 @@ Verify that the expected resources exist in the target resource group: - API web app - task function app - ACR -- Key Vault +- Key Vault, and the keyring vault - SQL server and database - optional Linux and AVD VMs - the `linuxbroker.internal` private DNS zone with an A record for each Linux host, unless `domainName` was supplied @@ -615,6 +679,8 @@ Confirm the vault contains: - `db-password` - `linux-host` +The keyring vault, `kr`, starts empty and gains a `keyring-` secret at each user's first checkout. The API app has **Key Vault Secrets Officer** on it and a `KEYRING_VAULT_URL` app setting that points at it. + ### SQL Confirm the database contains the expected tables and procedures. @@ -762,7 +828,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` @@ -783,13 +860,38 @@ 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 +### A RHEL session is stuck on a lock screen that will not accept the password -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. +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 RHEL session is stuck on a lock screen that will not accept the password +### 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 +``` + +### Applications ask for a password to unlock the login keyring + +The session launcher logs what it did with the user's keyring under the `linuxbroker-startwm` tag, and the key the broker sent, if any, is in `/run/linuxbroker-keyring/`: + +```bash +sudo journalctl -t linuxbroker-startwm -n 20 +sudo ls -l /run/linuxbroker-keyring/ +``` + +- No key file means the host received no key. Check that the API app has the `KEYRING_VAULT_URL` setting, which `azd provision` adds, and that **Fleet health** shows the host on agent 1.2.0. The API logs `Could not use the keyring key` when Key Vault refused a request, for example while a new role assignment is still propagating, and then sends no key from that worker for five minutes. It logs `a deleted secret with that name must be recovered or purged first` when that user's secret was deleted; recover it, or purge it to give the user a new key. +- `The login keyring of stays locked: the key does not open it.` means another password protects the keyring, and the launcher left it alone because it did not start the keyring daemon itself, typically because one from an earlier session of the user was still running. The next session that starts without one moves the old keyring aside and creates a new one. +- No `linuxbroker-startwm` entries for the session mean it did not start through the launcher; see the previous entry. + +### The idle timeout does not disconnect anyone + +Check `/var/log/release-session.log` on the host once the session has been idle for longer than the timeout: -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. +- `Could not read idle time for user . Skipping idle enforcement.` means `xprintidle` is missing. RHEL, Rocky Linux and AlmaLinux do not package it, so only Ubuntu hosts enforce the timeout. +- `No xrdp connection process was found for user on display ` every minute means the host runs an agent older than 1.2.0, which never found the connection; migrate it. On agent 1.2.0 it means xrdp runs with `fork=false` in `/etc/xrdp/xrdp.ini`, so one xrdp process carries every connection on the host, and the agent does not end it. ### The Custom Script Extension failed on the screen lock step diff --git a/deploy/Initialize-DeploymentEnvironment.ps1 b/deploy/Initialize-DeploymentEnvironment.ps1 index 72ccaf9..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, @@ -1054,8 +1084,9 @@ 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 '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 @@ -1100,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 @@ -1236,6 +1277,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/Migrate-LinuxHostReleaseAgent.ps1 b/deploy/Migrate-LinuxHostReleaseAgent.ps1 index 66ce43d..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__ @@ -215,6 +218,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' @@ -274,6 +278,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.' @@ -290,22 +383,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" @@ -313,6 +392,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" @@ -324,8 +404,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" @@ -448,6 +529,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 fa136ff..9d33ae4 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -143,17 +143,26 @@ param linuxHostAuthType string = 'SSH' param linuxHostSshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '24_04-lts' ]) -@description('Linux host OS image SKU.') -param linuxHostOsVersion string = '24_04-lts' +@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 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 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([ + '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 = '' @@ -236,6 +245,7 @@ module resources 'main.resources.bicep' = { linuxHostSshPublicKey: linuxHostSshPublicKey linuxHostOsVersion: linuxHostOsVersion linuxHostDisableScreenLock: linuxHostDisableScreenLock + linuxHostDesktop: linuxHostDesktop avdHostPoolName: avdHostPoolName avdSessionHostCount: avdSessionHostCount avdMaxSessionLimit: avdMaxSessionLimit @@ -251,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 8a0da84..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": "8661975720375439198" + "templateHash": "9307409657661009644" } }, "parameters": { @@ -312,22 +312,35 @@ }, "linuxHostOsVersion": { "type": "string", - "defaultValue": "24_04-lts", + "defaultValue": "9-LVM", "allowedValues": [ - "7-LVM", "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": { "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 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": { + "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": { @@ -528,6 +541,9 @@ "linuxHostDisableScreenLock": { "value": "[parameters('linuxHostDisableScreenLock')]" }, + "linuxHostDesktop": { + "value": "[parameters('linuxHostDesktop')]" + }, "avdHostPoolName": { "value": "[parameters('avdHostPoolName')]" }, @@ -551,7 +567,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "1069093875186774374" + "templateHash": "7015886803379737196" } }, "parameters": { @@ -756,11 +772,12 @@ }, "linuxHostOsVersion": { "type": "string", - "defaultValue": "24_04-lts", + "defaultValue": "9-LVM", "allowedValues": [ - "7-LVM", "8-LVM", "9-LVM", + "rocky-9", + "alma-9", "24_04-lts" ] }, @@ -768,7 +785,19 @@ "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 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": { @@ -811,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", @@ -827,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", @@ -1014,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", @@ -1844,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", @@ -2345,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')]", @@ -2530,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')]", @@ -2832,6 +2961,9 @@ }, "disableScreenLock": { "value": "[parameters('linuxHostDisableScreenLock')]" + }, + "desktop": { + "value": "[parameters('linuxHostDesktop')]" } }, "template": { @@ -2841,7 +2973,7 @@ "_generator": { "name": "bicep", "version": "0.44.1.10279", - "templateHash": "15055457811505930858" + "templateHash": "11700213154129506841" } }, "parameters": { @@ -2899,9 +3031,10 @@ "OSVersion": { "type": "string", "allowedValues": [ - "7-LVM", "8-LVM", "9-LVM", + "rocky-9", + "alma-9", "24_04-lts" ] }, @@ -2916,7 +3049,19 @@ "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 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": { + "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." } } }, @@ -2930,22 +3075,11 @@ ], "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": { - "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", @@ -2970,6 +3104,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", @@ -3020,6 +3185,7 @@ "name": "[variables('vmNames')[copyIndex()]]", "location": "[parameters('location')]", "tags": "[parameters('tags')]", + "plan": "[tryGet(variables('selectedConfig'), 'plan')]", "identity": { "type": "SystemAssigned" }, @@ -3038,7 +3204,8 @@ "storageProfile": { "imageReference": "[variables('selectedConfig').image]", "osDisk": { - "createOption": "FromImage" + "createOption": "FromImage", + "diskSizeGB": "[tryGet(variables('selectedConfig'), 'osDiskSizeGB')]" } }, "securityProfile": { @@ -3830,6 +3997,10 @@ "type": "string", "value": "[variables('keyVaultName')]" }, + "keyringVaultName": { + "type": "string", + "value": "[variables('keyringVaultName')]" + }, "containerRegistryName": { "type": "string", "value": "[variables('containerRegistryName')]" @@ -3891,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.parameters.example.json b/deploy/bicep/main.parameters.example.json index 0feb423..62d8e53 100644 --- a/deploy/bicep/main.parameters.example.json +++ b/deploy/bicep/main.parameters.example.json @@ -123,11 +123,14 @@ "value": "SSH" }, "linuxHostOsVersion": { - "value": "24_04-lts" + "value": "9-LVM" }, "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 321f888..4eef7dc 100644 --- a/deploy/bicep/main.resources.bicep +++ b/deploy/bicep/main.resources.bicep @@ -81,16 +81,25 @@ param linuxHostCount int = 0 param linuxHostAuthType string = 'SSH' param linuxHostSshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '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.') +@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 @@ -115,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' @@ -131,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' @@ -211,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: { @@ -243,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 } @@ -378,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 @@ -507,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' @@ -540,6 +578,7 @@ module linuxHosts 'modules/Linux/main.bicep' = if (deployLinuxHosts && linuxHost linuxBrokerApiClientId: apiClientId scriptSourceRoot: scriptSourceRoot disableScreenLock: linuxHostDisableScreenLock + desktop: linuxHostDesktop } } @@ -573,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/Linux/main.bicep b/deploy/bicep/modules/Linux/main.bicep index dcd9445..ce54c42 100644 --- a/deploy/bicep/modules/Linux/main.bicep +++ b/deploy/bicep/modules/Linux/main.bicep @@ -24,9 +24,10 @@ param adminPassword string param sshPublicKey string = '' @allowed([ - '7-LVM' '8-LVM' '9-LVM' + 'rocky-9' + 'alma-9' '24_04-lts' ]) param OSVersion string @@ -34,12 +35,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 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 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([ + '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' ? { @@ -62,20 +74,14 @@ 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. +// 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 = { - '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' @@ -100,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' @@ -156,6 +193,7 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ name: name location: location tags: tags + plan: selectedConfig.?plan identity: { type: 'SystemAssigned' } @@ -179,6 +217,7 @@ resource vmLinuxHost 'Microsoft.Compute/virtualMachines@2022-03-01' = [ imageReference: selectedConfig.image osDisk: { createOption: 'FromImage' + diskSizeGB: selectedConfig.?osDiskSizeGB } } securityProfile: { 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/docs/ROADMAP.md b/docs/ROADMAP.md index bacd888..4c035af 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -502,6 +502,10 @@ restarts in 10 minutes"). It reuses the `notify-send`/`xmessage` path in ## Phase 3: operating system and desktop support +**Status: Done, except the 3.5 spike.** Items 3.1–3.4, 3.6 and 3.7 shipped together, with host +agent 1.2.0. Each has a **Shipped** note on where it differs from the design below it, and 3.8 +lists what the validation left open. + ### 3.0 Support matrix | Target | Today | Target state | @@ -514,6 +518,12 @@ restarts in 10 minutes"). It reuses the `notify-send`/`xmessage` path in | Ubuntu 26.04 | Not offered. GNOME is Wayland-only (Ubuntu 25.10 dropped "Ubuntu on Xorg", and GNOME 49 removed X11). Xorg remains for other desktops. | XFCE/MATE with xrdp (3.2), or the 3.5 backend | | Rocky / Alma 9 | Not offered | Optional (3.7) | +**After Phase 3.** RHEL 7 is gone (3.3). RHEL 8 and 9, Rocky Linux 9 and AlmaLinux 9 (3.7), and +Ubuntu 24.04 (3.1) each run GNOME, Xfce or MATE (3.2), and RHEL 9 is the default. The validation +covered Xfce and MATE on RHEL 9 and Ubuntu, and GNOME on every distribution except Rocky. RHEL 10 +still needs the 3.5 backend. Ubuntu 26.04 could run Xfce or MATE on Xorg, but isn't offered yet +(3.8). + The current design depends on **Xorg** in three places: - xrdp's `xorgxrdp` backend. - Session inspection: `xrdp-who-xorg.sh` uses `ps -C Xorg` and the `xrdp_display` sockets. @@ -524,7 +534,39 @@ Xorg, or a different remoting stack. ### 3.1 Ubuntu 24.04 as a real desktop target -**Status: Planned** · no dependencies +**Status: Done** · no dependencies + +**Shipped.** The default `linuxHostOsVersion` is `9-LVM`, `Configure-Ubuntu24_desktop-Host.sh` +installs a desktop, and one release agent serves every distribution. Where it differs from the +design below: + +- `24_04-lts` keeps its name and still maps to Canonical's server image, and the bootstrap adds + `ubuntu-desktop-minimal`. There is no `24_04-desktop` value. +- A session launcher, `linux_host/xrdp-startwm.sh`, replaces editing `startwm.sh`. The bootstrap + and the host migration make it xrdp-sesman's `DefaultWindowManager` on every distribution. It + starts the desktop named in `/etc/linuxbroker/desktop.conf`, which for GNOME on Ubuntu is + Ubuntu's session on Xorg, unlocks the login keyring (3.4), and puts `/etc/linuxbroker/xdg` + ahead of `/etc/xdg`. Without `desktop.conf`, as on a migrated host, it runs the distribution's + own script. +- The launcher's `--install` also adds the polkit rule for the color-profile and PackageKit + refresh prompts (`45-linuxbroker-xrdp.rules`), so every distribution gets it. +- The bootstrap leaves out the first-login wizard and whoopsie, disables apport and hides update + notifications from broker users. GNOME 40's welcome tour, which RHEL 9 and AlmaLinux 9 showed + at first login, is marked as seen too. Firefox installs on its own, so an unreachable Snap + Store can't fail the host, and the snap works with the NFS home (3.4), so Mozilla's APT + repository isn't needed. +- The bootstrap no longer adds Microsoft's package repository or the Azure CLI. That repository + has no `azure-cli` package for 24.04, so the old bootstrap's package install failed, and + existing Ubuntu hosts lack `nfs-common`, `jq` and `dconf-cli`. They must be replaced or + bootstrapped again. +- `ubuntu-desktop-minimal` brings NetworkManager and a netplan file that hands every interface to + it. The next restart of `systemd-networkd`, which needrestart did when the Defender for Endpoint + extension installed a package, left the host off the network until it rebooted. The bootstrap + now keeps networkd as netplan's renderer. The cost is that GNOME shows no network indicator + (3.8). +- Broker users get `/bin/bash` instead of Ubuntu's default `/bin/sh`. +- `Migrate-LinuxHostReleaseAgent.ps1` failed on every Ubuntu host: Run Command starts a script + without a `#!` line with dash, which stopped at `set -o pipefail`. It now declares bash. **Quick win, do it first:** change the Bicep default `linuxHostOsVersion` from `24_04-lts` to `9-LVM` until Ubuntu is complete. Today a default `azd up` with Linux hosts produces @@ -556,7 +598,24 @@ the AVD RemoteApp, and passes the 3.4 checklist. ### 3.2 Desktop environment choice -**Status: Planned** · after 3.1 +**Status: Done** · after 3.1 + +**Shipped.** `linuxHostDesktop` (`gnome`, `xfce` or `mate`) for every bootstrap, MATE and Xfce +branches in `apply-host-settings.sh`, and a note on **Host Settings** when the fleet runs Xfce or +MATE. Where it differs from the design below: + +- MATE is offered on every distribution, as Xfce is. The bootstrap records the desktop in + `/etc/linuxbroker/desktop.conf`, and the session launcher (3.1) starts it. +- MATE takes the screen lock policy from dconf (`org.mate.screensaver`, and + `org.mate.lockdown disable-lock-screen`), and Xfce from xfconf system defaults with kiosk locks. + Both count the blank and lock delays in whole minutes, up to 8 hours, and Xfce sessions read a + change only when they start. +- Ubuntu MATE's panel uses MATE's own `default` layout. The Ubuntu MATE layout, which Ubuntu's + MATE settings package makes the default, needs three applets the core MATE packages leave out, + so every new user was asked to delete three broken applets. +- With `gnome`, the extension command is unchanged, so hosts that keep the default see no change. +- Measured in 3.4, a session used 0.6–0.9 GB of memory with Xfce or MATE, 1.5–2.1 GB with GNOME, + and 2.8–3.0 GB with GNOME and Firefox open. **Why.** GNOME Shell under xrdp renders in software (llvmpipe) and costs a lot of CPU per session. XFCE and MATE are native X11 desktops: much lighter, and not affected by GNOME's @@ -573,7 +632,11 @@ move away from X11. That matters even more with multi-session hosts (4.3). ### 3.3 Retire RHEL 7 -**Status: Planned** · no dependencies +**Status: Done** · no dependencies + +**Shipped** as designed. An azd environment that still stores `7-LVM` fails template validation at +the next `azd provision`, even with `deployLinuxHosts=false`, so the upgrade notes say to change +it first. `patch-host.sh` and the host migration still support existing RHEL 7 hosts. Remove `7-LVM` from `deploy/bicep/main.bicep`, `main.resources.bicep`, `modules/Linux/main.bicep` and the regenerated `main.json`. Delete `custom_script_extensions/Configure-RHEL7-Host.sh` @@ -582,7 +645,43 @@ but new deployments can't choose it. ### 3.4 GNOME validation and the login keyring -**Status: Planned** · run it on the RHEL 9 fleet now, and on Ubuntu after 3.1 +**Status: Done** · after 3.1; the checks that need `mstsc` are open in 3.8 + +**Shipped.** The checklist ran on GNOME, Xfce and MATE on Ubuntu 24.04 and RHEL 9, and on GNOME +on RHEL 8 and AlmaLinux 9, with FreeRDP 3.31 as the RDP client. Each item below notes its result. +It confirmed that nothing unlocked a login keyring at sign-in, and the keyring now unlocks with +a variant of option 1: + +- The account password still changes at every checkout. Instead, the broker keeps a random key + for each user, the secret `keyring-` in a second Key Vault that holds nothing else, and + the API has write access to that vault only. The API creates the key at the user's first + checkout and sends it to the host with the password. +- `create-user.sh` writes the key to `/run/linuxbroker-keyring/`, which is in memory and + readable only by the user. Releasing the lease removes it. +- The session launcher (3.1) unlocks the login keyring with the key before the desktop starts, or + creates the keyring at first sign-in. It then checks over D-Bus that the keyring is unlocked, + because `gnome-keyring-daemon --unlock` succeeds even with the wrong key. +- A keyring the key doesn't open, such as one a user created with a password of their own before + the upgrade, is moved to `~/.local/share/linuxbroker/keyring-backup/` and replaced. +- An applied profile reset writes a new version of the key. The older versions stay, so the + keyring kept with the old profile can still be opened. +- The keyring never blocks a sign-in. Without a key, the desktop starts with the keyring locked, + as it did before. +- No PAM file changes. Neither family has `pam_gnome_keyring` in xrdp-sesman's stack, and + `chpasswd` runs as root without the old password, so the module couldn't re-key a keyring. + +The validation also found four agent faults, fixed in the same release: + +- The idle timeout had never disconnected anyone, on any distribution: the agent looked for the + xrdp connection by a socket path that `ss` prints only for the X server's end. It now follows + the socket's peer. +- A resumed session was disconnected again at once, because the X server's idle counter runs on + while no one is connected. Idle time now counts from no earlier than the current connection. +- Tracker crawled the roaming homes. On RHEL 9 it restarted every 13 seconds on an index that + Ubuntu's newer Tracker had written, reading about 0.6 MB/s from the share. Hosts now mask the + Tracker and LocalSearch user services and hide their autostart entries. +- When a grace period expired, the agent logged an error because the Xorg it had just killed was + still exiting. It now waits up to five seconds. **Why.** Every checkout sets a **new random password** (`generate_secure_password()` then `chpasswd` in `api/app.py`), and the home directory roams on NFS. GNOME Keyring encrypts the @@ -592,25 +691,52 @@ libsecret apps would then prompt for a password the user never knew. This needs confirming on a real host. **Test checklist** (per distribution and desktop): -- [ ] With **Keep sessions alive during the grace period** on (Phase 1, off by default): +- [x] With **Keep sessions alive during the grace period** on (Phase 1, off by default): disconnect and reconnect within grace resumes the same desktop, including from a different AVD host. An idle disconnect resumes. At grace expiry the session and Xorg are gone and cleanup completes. Memory held by disconnected sessions stays within the VM size. -- [ ] First login; second login on a *different* host; keyring unlock prompts. -- [ ] Browser: the Firefox snap (Ubuntu) and Flatpak (RHEL 10+) behave with an NFS home. + **Result.** Passed, including from a second AVD host and with Firefox open. The idle + disconnect needed the two idle fixes above. Hosts with disconnected sessions used 1.2 to + 3.5 GB of their 7.6 GB. +- [x] First login; second login on a *different* host; keyring unlock prompts. + **Result.** Passed with the keyring key on RHEL 8, RHEL 9 and Ubuntu GNOME, with no + prompts. Secrets stored on Ubuntu were readable on RHEL 9, and the other way round. +- [x] Browser: the Firefox snap (Ubuntu) and Flatpak (RHEL 10+) behave with an NFS home. Profile lock after an unclean disconnect. -- [ ] Disconnect, then reconnect inside the grace period and resume the same session. -- [ ] Reconnect after the grace period expires: a fresh session with the profile intact. -- [ ] The idle warning appears, and the idle disconnect preserves the session. + **Result.** The Firefox snap works with the NFS home. Firefox killed on AlmaLinux reopened + on RHEL 9 with its tabs and no "already running" prompt, because NFS 4.1 released the + lock. Flatpak waits for RHEL 10 (3.5). +- [x] Disconnect, then reconnect inside the grace period and resume the same session. + **Result.** Passed: the same X server, windows and unlocked keyring. +- [x] Reconnect after the grace period expires: a fresh session with the profile intact. + **Result.** Passed. Both test users landed on a different host, with the keyring unlocked + and their secrets intact. +- [x] The idle warning appears, and the idle disconnect preserves the session. + **Result.** The warning appeared on Ubuntu, and after the idle fixes the disconnect kept + the session. RHEL, Rocky and AlmaLinux have no `xprintidle`, so they neither warn nor + disconnect (3.8). - [ ] Clipboard in both directions, audio, resolution change, multi-monitor, full screen. -- [ ] Screen lock posture matches the Host Settings profile, and lock after reconnect. -- [ ] GNOME Tracker/LocalSearch and other indexers aren't crawling the NFS home. -- [ ] `~/.cache` size and I/O on NFS during normal use (feeds 4.6). -- [ ] Log off cleans up: the account is removed, the home is unmounted, and the profile on + **Result.** The clipboard works both ways on every desktop. The rest needs `mstsc` + (3.8): resizing the window works on RHEL 9 but drops FreeRDP 3.31 on Ubuntu (xrdp + 0.9.24, neutrinolabs/xrdp#3877), RDP audio can work only on Ubuntu GNOME, and the test + client can't show several monitors. +- [x] Screen lock posture matches the Host Settings profile, and lock after reconnect. + **Result.** Passed on every desktop, with the default profile and with a STIG-style one + that locks after 60 idle seconds. A resumed session was checked only with the lock off. + A session that is already idle needs one input before a new idle delay applies. +- [x] GNOME Tracker/LocalSearch and other indexers aren't crawling the NFS home. + **Result.** Failed, then fixed (see above). With the fix, no indexer runs, and an idle + session moves at most 208 bytes over NFS in a minute. +- [x] `~/.cache` size and I/O on NFS during normal use (feeds 4.6). + **Result.** 1.3 to 19 MB per user. With no indexer, I/O is only what the user's + applications do. +- [x] Log off cleans up: the account is removed, the home is unmounted, and the profile on the share is intact. + **Result.** Passed, from the desktop's own log-off on Xfce and GNOME. -**Keyring fix options** (decide after testing): +**Keyring fix options.** The first was chosen, with a key that is separate from the password; +see **Shipped** above. 1. A stable per-user secret instead of a password rotated at every checkout. Store it in Key Vault keyed by `VmUsers.uid`, and rotate it only on profile reset. This is the simplest, but it changes the credential model. @@ -647,7 +773,13 @@ work is estimated as a follow-up item. ### 3.6 xpra application mode -**Status: Planned (decision)** +**Status: Done (removed)** + +**Shipped.** xpra is removed. New hosts don't install it and allow only SSH and RDP, and +`Migrate-LinuxHostReleaseAgent.ps1` removes xpra's repository, packages and signing key from +existing hosts and closes TCP 443. `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. Publishing single Linux applications is a follow-up (3.8). `avd_host/broker/Connect-LinuxBroker.ps1` accepts an application name for xpra mode, but the branch is a stub (`# Add XPRA command`). The host scripts still install xpra from @@ -660,11 +792,52 @@ applications". Either: ### 3.7 Rocky Linux and AlmaLinux 9 (optional) +**Status: Done** · no dependencies + +**Shipped.** `linuxHostOsVersion` accepts `rocky-9` and `alma-9`, and both run +`Configure-RHEL9-Host.sh`. Where it differs from the design below: + +- The bootstrap reads the distribution from `os-release`. On Rocky and AlmaLinux it skips + subscription registration, enables CRB, installs EPEL from the distribution's own + `epel-release` package, and installs firewalld, which their Azure images leave out. +- Both get the 64 GB OS disk RHEL hosts have, because their images are 10 GB and 30 GB. +- Rocky's image is a free Marketplace offer with a purchase plan. Preprovision accepts its terms, + but the subscription must also be allowed to buy Marketplace offers, so `DEPLOYMENT.md` + recommends `alma-9` where it isn't. +- AlmaLinux 9 with GNOME passed the 3.4 checklist. Rocky Linux 9 is not validated on a live host, + because the test subscription can't buy Marketplace offers (3.8). + The RHEL 9 script works with little change: skip `subscription-manager` and use the distribution's CRB repository name. That's useful where RHEL subscriptions are an obstacle. Add `rocky-9` and `alma-9` image mappings. `Migrate-LinuxHostReleaseAgent.ps1` already treats `rocky` and `almalinux` as RHEL-like. +### 3.8 Follow-ups from the Phase 3 validation + +**Status: Planned** · no dependencies + +The 3.4 validation left these open. The first three need checking with `mstsc`, the client the +broker starts, which the test client couldn't stand in for. Items marked *pre-existing* were +there before Phase 3. + +| Item | Why | Direction | +| --- | --- | --- | +| Checks with `mstsc` | The test client couldn't present several monitors or play sound, and full screen and multi-monitor depend on the user's `Default.rdp`, because the launcher runs `mstsc /v:` | Through AVD, on an Ubuntu and a RHEL host: resize and maximize the window, use full screen and two monitors, and play sound on Ubuntu GNOME | +| Resizing on Ubuntu | A live resize dropped FreeRDP 3.31 on xrdp 0.9.24 (neutrinolabs/xrdp#3877), and with **Keep sessions alive** off the agent then ended the session. RHEL 9's xrdp 0.10.6 resized fine. | If `mstsc` drops too, have the launcher write an `.rdp` file with `dynamic resolution:i:0` (4.8), or ship a fixed xrdp | +| RDP audio | RHEL, Rocky and AlmaLinux package no xrdp audio module. On Ubuntu, Xfce runs PulseAudio, which has no xrdp module in the archive, and MATE starts no sound server, so only GNOME, with PipeWire and `pipewire-module-xrdp`, can play sound. | Build or ship `pipewire-module-xrdp` for the RHEL family, and run PipeWire in Xfce and MATE sessions | +| Idle timeout on the RHEL family | RHEL, Rocky and AlmaLinux don't package `xprintidle`, even in EPEL, so those hosts neither warn nor disconnect idle sessions | Read the X idle time another way, for example with `python3` calling `XScreenSaverQueryInfo` in `libXss` through `ctypes` | +| Host reboot during a lease (*pre-existing*) | The lease file survives the reboot, but the home isn't mounted again and the agent has no record of the user, so the host stays checked out until someone returns it, and a reconnect lands in an empty local home | Reconcile leases at boot: mount the home again, or release the lease | +| Group IDs differ between hosts (*pre-existing*) | Only the uid is pinned. The user's own group, `tsusers` and `appusers` get whatever gid is free on each host, so group ownership of roaming files means different groups on different hosts. It does no harm while homes are `700`. | Create the user's group with gid = uid and give the shared groups fixed gids, with a migration for existing hosts | +| Ubuntu 26.04 | Its GNOME is Wayland-only, but Xfce and MATE still run on Xorg | Offer it with Xfce or MATE. GNOME waits for 3.5, as RHEL 10 does. | +| Publishing single applications | With xpra gone (3.6), the broker publishes only full desktops | Evaluate xrdp's RemoteApp (RAIL) support, or what the 3.5 backend offers | +| No network indicator in Ubuntu's GNOME | systemd-networkd runs the network (3.1), so GNOME shows no network icon, and applications that ask NetworkManager, such as GNOME Software, may think the host is offline | Document it, or let NetworkManager manage the NIC without netplan removing networkd's configuration | +| No browser in Xfce and MATE on the RHEL family | Only the GNOME install brings Firefox | Install Firefox with every desktop on the RHEL family | +| First-login windows on RHEL 8 GNOME | A new profile shows Getting Started and a "System Not Registered" notice | Hide both, as 3.1 does for GNOME 40's tour | +| Lock Screen entry in Xfce | With the lock off, Xfce's action menu still shows **Lock Screen**, which does nothing | Hide the entry when the Host Settings profile turns the lock off | +| Rocky Linux 9 on a live host | The test subscription can't buy Marketplace offers, so only AlmaLinux 9 was validated | Validate `rocky-9` in a subscription that can | +| Two keyring daemons on Ubuntu GNOME | GNOME starts its own `gnome-keyring-daemon.service` next to the launcher's. It owns no bus names and opens no keyring, and uses about 10 MB. | Have the launcher start that user unit instead of its own daemon | +| Xfce sessions stay "closing" on RHEL 9 | GeoClue's demo agent outlives the X server, so after a disconnect with **Keep sessions alive** off the login session stays "closing" until the grace period ends. Nobody sees it, and the agent goes by Xorg. | Add its autostart entry to the ones the launcher hides | + --- ## Phase 4: strategic scale @@ -738,6 +911,11 @@ lease files, releases and grace timers). MemoryMax), private `/tmp` per user (`pam_namespace`), and per-host session caps. - Portal: hosts show N/M sessions, and the Sessions page (2.3) becomes the primary view. +**Measured in 3.4.** A session used 0.6 to 0.9 GB of memory with Xfce or MATE, 1.5 to 2.1 GB +with GNOME, and 2.8 to 3.0 GB with GNOME and Firefox open. A preserved session keeps its memory +while it's disconnected. On a host with 8 GB, that leaves room for several Xfce or MATE sessions +but only two or three GNOME ones. + **Open questions.** Sizing guidance per desktop. Noisy-neighbor limits. Whether some users or pools should stay single-session (see 4.4). @@ -776,11 +954,17 @@ defaults to 100 GiB (`deploy/bicep/modules/core/nfs-storage.bicep`). Premium per scales with provisioned size: 100 GiB gives roughly 3,100 baseline IOPS. Desktop sessions generate many small I/Os, especially browser and GNOME caches and indexers. +**Measured in 3.4.** After normal use, `~/.cache` held 1.3 to 19 MB per user. With the file +indexer off, an idle session moved at most 208 bytes over NFS in a minute. Before that, one +Tracker that kept failing on an index written by another distribution's Tracker read about +0.6 MB/s from the share. + **Design.** - 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 @@ -871,6 +1055,15 @@ A separate track, prioritized independently of the phases. | 2026-09 | Import from Azure requires DNS (`.`); there are no typed-in IPs, and imported hosts start unreachable until the probe reaches them. | | 2026-09 | Checkout and host-start events are kept for `CHECKOUT_EVENT_RETENTION_DAYS` (default 90) and purged with the audit log. | | 2026-09 | Broker timestamps are UTC; the portal shows relative times with the absolute UTC time as a tooltip. Keyboard shortcuts can be turned off. | +| 2026-09 | Phase 3 ships in one PR, one commit per item, with one host agent rollout (1.2.0: the merged release agent, the xrdp session launcher, the keyring key and the xpra cleanup). The 3.5 spike stays separate. | +| 2026-09 | New deployments default to RHEL 9 (`9-LVM`). RHEL 7 is no longer offered, but existing RHEL 7 hosts keep working. | +| 2026-09 | xpra is removed rather than implemented. Any `-Mode` opens the desktop, so existing RemoteApps keep working. | +| 2026-09 | The login keyring unlocks with a random key per user, kept in its own Key Vault, instead of making the account password stable. The password still changes at every checkout. A keyring the key can't open is moved aside once and replaced, and a profile reset writes a new key version. The keyring never blocks a sign-in, and no PAM file changes. | +| 2026-09 | Every distribution starts sessions through the broker's xrdp session launcher, which falls back to the distribution's own script on hosts with no `/etc/linuxbroker/desktop.conf`. | +| 2026-09 | The Tracker and LocalSearch file indexers are off in broker sessions, because their databases live in the roaming home and don't open across distributions. | +| 2026-09 | Ubuntu desktop hosts keep systemd-networkd as the network renderer, at the cost of GNOME's network indicator, so a package install can't take a host off the network. | +| 2026-09 | Idle time never counts from before the user's current connection, so a resumed session isn't disconnected for the time it spent disconnected. | +| 2026-09 | Ubuntu 26.04 and RHEL 10 wait for 3.5 or 3.8. Rocky Linux 9 is offered through its Marketplace image, and `alma-9` is recommended where Marketplace purchases are blocked. | ## Glossary diff --git a/front_end/app.py b/front_end/app.py index 7d3e1f5..e88ae41 100644 --- a/front_end/app.py +++ b/front_end/app.py @@ -30,7 +30,7 @@ app = Flask(__name__) app.config['SECRET_KEY'] = os.environ.get('FLASK_KEY') app.config['SESSION_TYPE'] = 'filesystem' -app.config['VERSION'] = '0.120' +app.config['VERSION'] = '0.121' # Tokens stay valid for the life of the session rather than expiring after an # hour, so a long-lived management page does not start rejecting submissions. app.config['WTF_CSRF_TIME_LIMIT'] = None diff --git a/front_end/web/package-lock.json b/front_end/web/package-lock.json index 5ca9d8b..5676b44 100644 --- a/front_end/web/package-lock.json +++ b/front_end/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "linux-broker-portal", - "version": "0.120.0", + "version": "0.121.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linux-broker-portal", - "version": "0.120.0", + "version": "0.121.0", "dependencies": { "@tanstack/react-query": "^5.62.0", "react": "^18.3.1", diff --git a/front_end/web/package.json b/front_end/web/package.json index 1737d26..134f111 100644 --- a/front_end/web/package.json +++ b/front_end/web/package.json @@ -1,7 +1,7 @@ { "name": "linux-broker-portal", "private": true, - "version": "0.120.0", + "version": "0.121.0", "type": "module", "description": "Service Management Portal for the Linux Broker for AVD Access solution.", "scripts": { 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..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" @@ -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/create-user.sh b/linux_host/create-user.sh index 51cb913..6b1db3d 100644 --- a/linux_host/create-user.sh +++ b/linux_host/create-user.sh @@ -5,13 +5,15 @@ # 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" 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,18 +173,35 @@ 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 +# 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 @@ -220,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..1f155cf 100644 --- a/linux_host/manage-lease.sh +++ b/linux_host/manage-lease.sh @@ -14,9 +14,11 @@ 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. +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/patch-host.sh b/linux_host/patch-host.sh index b54f5f8..c9a282b 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. @@ -24,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" @@ -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/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/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/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/RHEL/release-session.sh b/linux_host/session_release_buffer/release-session.sh similarity index 86% rename from linux_host/session_release_buffer/RHEL/release-session.sh rename to linux_host/session_release_buffer/release-session.sh index 12a7361..ea6077f 100644 --- a/linux_host/session_release_buffer/RHEL/release-session.sh +++ b/linux_host/session_release_buffer/release-session.sh @@ -1,18 +1,20 @@ #!/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" # 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" 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" @@ -83,6 +85,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" @@ -278,10 +307,21 @@ xorg_processes_for_user() { ps h -C Xorg -o pid=,user=,comm= 2>/dev/null | awk -v user="$username" '$2 == user {print $1 ":" $3}' } +# A killed Xorg can take a moment to exit and be reaped by xrdp-sesman, so it gets a few +# seconds before it counts as remaining. +XORG_EXIT_WAIT_SECONDS=5 + xorg_processes_remaining() { local username="$1" + local waited=0 - [ -n "$(xorg_processes_for_user "$username")" ] + while [ -n "$(xorg_processes_for_user "$username")" ]; do + [ "$waited" -ge "$XORG_EXIT_WAIT_SECONDS" ] && return 0 + sleep 1 + waited=$((waited + 1)) + done + + return 1 } terminate_session_processes() { @@ -509,6 +549,8 @@ get_session_idle_seconds() { local display local xauthority local idle_milliseconds + local idle_seconds + local connected_seconds if ! command -v xprintidle >/dev/null 2>&1; then return 1 @@ -531,7 +573,17 @@ get_session_idle_seconds() { return 1 fi - echo $((idle_milliseconds / 1000)) + idle_seconds=$((idle_milliseconds / 1000)) + + # X keeps counting while a session sits disconnected, and reconnecting sends it no input, + # so the idle time would carry over into the new connection and disconnect the user again + # as soon as they reconnected. No connection has been idle for longer than it has been open. + connected_seconds=$(session_connected_seconds "${display#:}" "$xorg_pid") + if [ -n "$connected_seconds" ] && [ "$connected_seconds" -lt "$idle_seconds" ]; then + idle_seconds="$connected_seconds" + fi + + echo "$idle_seconds" } warn_idle_user() { @@ -551,7 +603,7 @@ warn_idle_user() { 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 + runuser -u "$username" -- notify-send --app-name="Linux Broker" "Idle session warning" "$message" >/dev/null 2>&1; then return 0 fi fi @@ -564,17 +616,85 @@ warn_idle_user() { return 1 } +# Prints the PIDs holding the client end of each connection to a session's display socket. +# ss prints a Unix socket's path only on the listening end, which is Xorg's, and shows the +# client end as "* * ". So the client end is the socket whose inode is the +# peer inode of one of Xorg's connections on the display socket. +xrdp_connection_pids() { + local display_number="$1" + local xorg_pid="$2" + + # Columns: Netid State Recv-Q Send-Q Local-Address Port Peer-Address Port Process + ss -xp 2>/dev/null | awk -v socket="/xrdp_display_${display_number}\$" -v owner="pid=${xorg_pid}," ' + { holders[$6] = $0 } + $5 ~ socket && index($0, owner) { peers[$8] = 1 } + END { + for (inode in peers) { + rest = holders[inode] + while (match(rest, /pid=[0-9]+/)) { + print substr(rest, RSTART + 4, RLENGTH - 4) + rest = substr(rest, RSTART + RLENGTH) + } + } + }' | sort -u +} + +# Prints the PID of each xrdp process serving a client connection to a session's display. +# Each connection has its own process, forked from the xrdp daemon. The daemon itself is +# never included: with fork=false it carries every session on the host. +xrdp_session_connections() { + local display_number="$1" + local xorg_pid="$2" + local pid + local parent_pid + + while read -r pid; do + [ -z "$pid" ] && continue + [ "$pid" = "$xorg_pid" ] && continue + + if [ "$(ps -p "$pid" -o comm= 2>/dev/null | xargs)" != "xrdp" ]; then + continue + fi + + parent_pid=$(ps -p "$pid" -o ppid= 2>/dev/null | xargs) + if [ -z "$parent_pid" ] || [ "$(ps -p "$parent_pid" -o comm= 2>/dev/null | xargs)" != "xrdp" ]; then + continue + fi + + echo "$pid" + done < <(xrdp_connection_pids "$display_number" "$xorg_pid") +} + +# Prints how many seconds ago the session's current client connected, which is the age of +# its newest connection process, or nothing when no connection process is found. +session_connected_seconds() { + local display_number="$1" + local xorg_pid="$2" + local pid + local age + local newest="" + + while read -r pid; do + age=$(ps -p "$pid" -o etimes= 2>/dev/null | xargs) + [[ "$age" =~ ^[0-9]+$ ]] || continue + + if [ -z "$newest" ] || [ "$age" -lt "$newest" ]; then + newest="$age" + fi + done < <(xrdp_session_connections "$display_number" "$xorg_pid") + + echo "$newest" +} + # 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. +# user can reconnect inside the grace period. Only the xrdp process holding the other end of +# the session's display connection is terminated, and closing that connection is the same +# signal xrdp-who-xorg.sh uses to decide that a session is disconnected. 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") @@ -583,30 +703,14 @@ disconnect_session() { 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 - ) + done < <(xrdp_session_connections "${display#:}" "$xorg_pid") if [ "$disconnected" != "true" ]; then log "No xrdp connection process was found for user $username on display $display." @@ -709,7 +813,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. @@ -742,18 +846,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() { @@ -1008,6 +1122,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/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/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 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_create_user.sh b/linux_host/tests/test_create_user.sh index 5cc42f8..bbf777d 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 @@ -101,8 +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 -legacy_fixture_rejects_new_form \ No newline at end of file +existing_users_get_bash_instead_of_sh +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_heartbeat.sh b/linux_host/tests/test_heartbeat.sh index 62d9862..87e040c 100644 --- a/linux_host/tests/test_heartbeat.sh +++ b/linux_host/tests/test_heartbeat.sh @@ -65,12 +65,14 @@ 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" - [ "$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" @@ -83,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" @@ -92,6 +94,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" @@ -149,5 +167,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_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_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_release_session.sh b/linux_host/tests/test_release_session.sh index cc37050..6358cdf 100644 --- a/linux_host/tests/test_release_session.sh +++ b/linux_host/tests/test_release_session.sh @@ -75,9 +75,20 @@ run_for_script() { printf '666 bob Xorg\n' > "$FAKE_PS_XORG" printf 'bob\t0\n' > "$DISCONNECTED_USERS_FILE" export FAKE_KILL_SURVIVES=1 + # A killed Xorg that exits while the agent waits for it counts as gone. + sleep() { : > "$FAKE_PS_XORG"; } reconcile_disconnected_user bob 0 2 + [ -z "$(get_disconnect_timestamp bob)" ] || fail "$label timestamp should be cleared when Xorg exits during the wait" + assert_not_contains_file "$LOG_FILE" "Xorg processes remain" + + printf '666 bob Xorg\n' > "$FAKE_PS_XORG" + printf 'bob\t0\n' > "$DISCONNECTED_USERS_FILE" + sleep() { :; } + reconcile_disconnected_user bob 0 2 + unset -f sleep unset FAKE_KILL_SURVIVES assert_eq "$(get_disconnect_timestamp bob)" "0" "$label timestamp should remain when Xorg survives" + assert_file_contains "$LOG_FILE" "Xorg processes remain for user bob" } aggregation_for_script() { @@ -147,7 +158,161 @@ 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 + +# ss prints a Unix socket's path only on the listening end, which is Xorg's, so an idle +# disconnect finds the xrdp end of the display connection by its inode. The ss lines are +# trimmed from RHEL 8 with xrdp 0.10; Ubuntu with xrdp 0.9 prints the same shape. +idle_disconnect_for_script() { + local script="$1" + local label="$2" + local signalled="$WORK_DIR/signalled-$label" + local forked + local unforked + + reset_work + # shellcheck source=/dev/null + . "$script" + LOG_FILE="$WORK_DIR/idle-$label.log" + STATE_DIRECTORY="$WORK_DIR" + IDLE_WARNED_USERS_FILE="$WORK_DIR/idle-warned-$label.tsv" + : > "$signalled" + + get_session_display() { echo ":12"; } + # 100 is the xrdp daemon. 200 and 300 are the connection processes it forked for this + # session and for the one on display :120, and the daemon shares its stdout with 200. + forked=$(cat <<'SS' +Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +u_str ESTAB 0 0 /run/xrdp/2008/xrdp_display_12 669282 * 669904 users:(("Xorg",pid=500,fd=7)) +u_str ESTAB 0 0 * 669904 * 669282 users:(("xrdp",pid=200,fd=27)) +u_str ESTAB 0 0 * 165288 * 165899 users:(("xrdp",pid=200,fd=2),("xrdp",pid=100,fd=2)) +u_str ESTAB 0 0 /run/xrdp/2009/xrdp_display_120 700001 * 700002 users:(("Xorg",pid=600,fd=7)) +u_str ESTAB 0 0 * 700002 * 700001 users:(("xrdp",pid=300,fd=27)) +SS +) + # With fork=false the daemon carries every connection itself. + unforked=$(printf '%s\n' \ + 'u_str ESTAB 0 0 /run/xrdp/2008/xrdp_display_12 669282 * 669904 users:(("Xorg",pid=500,fd=7))' \ + 'u_str ESTAB 0 0 * 669904 * 669282 users:(("xrdp",pid=100,fd=27))') + FAKE_SS="$forked" + ss() { printf '%s\n' "$FAKE_SS"; } + # Answers "ps -p PID -o comm=", "-o ppid=" and "-o etimes=". Connection 200 has been open + # for FAKE_CONNECTED_SECONDS. + FAKE_CONNECTED_SECONDS=5000 + ps() { + case "$2:$4" in + 100:comm=|200:comm=|300:comm=) echo xrdp ;; + 500:comm=|600:comm=) echo Xorg ;; + 1:comm=) echo systemd ;; + 100:ppid=) echo 1 ;; + 200:ppid=|300:ppid=) echo 100 ;; + 100:etimes=) echo 90000 ;; + 200:etimes=) echo "$FAKE_CONNECTED_SECONDS" ;; + 300:etimes=) echo 5 ;; + *) return 1 ;; + esac + } + kill() { echo "${*: -1}" >> "$signalled"; } + + disconnect_session bob 500 || fail "$label found no xrdp connection for display :12" + assert_eq "$(cat "$signalled")" "200" "$label signals only the connection process of display :12" + assert_file_contains "$LOG_FILE" "Disconnected idle xrdp connection 200 for user bob." + + # With fork=false the daemon carries every session on the host, so it is left alone. + FAKE_SS="$unforked" + : > "$signalled" + if disconnect_session bob 500; then + fail "$label disconnected through the xrdp daemon" + fi + assert_eq "$(cat "$signalled")" "" "$label leaves the xrdp daemon alone" + assert_file_contains "$LOG_FILE" "No xrdp connection process was found for user bob on display :12." + + # The idle warning goes to the session's display as its owner, under the same name as the + # other broker notifications. + printf '#!/bin/bash\nexit 0\n' > "$SHIM_DIR/notify-send" + chmod +x "$SHIM_DIR/notify-send" + id() { echo 3102; } + get_session_xauthority() { echo /home/bob/.Xauthority; } + runuser() { printf '%s DISPLAY=%s DBUS=%s\n' "$*" "$DISPLAY" "$DBUS_SESSION_BUS_ADDRESS" >> "$WORK_DIR/runuser-$label"; } + warn_idle_user bob 500 90 || fail "$label idle warning was not delivered" + assert_file_contains "$WORK_DIR/runuser-$label" "bob -- notify-send --app-name=Linux Broker Idle session warning Your session has been idle and will be disconnected in 90 seconds." + assert_file_contains "$WORK_DIR/runuser-$label" "DISPLAY=:12 DBUS=unix:path=/run/user/3102/bus" + + # X keeps counting while a session sits disconnected, and reconnecting sends it no input, + # so the idle time is capped at how long the current connection has been open. + printf '#!/bin/bash\necho 2900000\n' > "$SHIM_DIR/xprintidle" + chmod +x "$SHIM_DIR/xprintidle" + FAKE_SS="$forked" + FAKE_CONNECTED_SECONDS=40 + assert_eq "$(get_session_idle_seconds 500)" "40" "$label idle time of a connection opened 40 seconds ago" + FAKE_CONNECTED_SECONDS=5000 + assert_eq "$(get_session_idle_seconds 500)" "2900" "$label idle time of a connection open for longer" + FAKE_SS="$unforked" + assert_eq "$(get_session_idle_seconds 500)" "2900" "$label idle time without a connection process" + + # A user who reconnects after an idle disconnect is not disconnected again straight away. + # The new connection is warned and then disconnected on the usual schedule. + IDLE_TIMEOUT_SECONDS=300 + IDLE_WARNING_SECONDS=120 + FAKE_SS="$forked" + : > "$signalled" + : > "$WORK_DIR/runuser-$label" + FAKE_CONNECTED_SECONDS=40 + enforce_idle_session bob 500 + assert_eq "$(cat "$signalled")" "" "$label disconnected a user who had just reconnected" + assert_eq "$(cat "$WORK_DIR/runuser-$label")" "" "$label warned a user who had just reconnected" + FAKE_CONNECTED_SECONDS=200 + enforce_idle_session bob 500 + assert_file_contains "$WORK_DIR/runuser-$label" "will be disconnected in 100 seconds." + assert_eq "$(cat "$signalled")" "" "$label disconnected a user inside the warning window" + FAKE_CONNECTED_SECONDS=400 + enforce_idle_session bob 500 + assert_eq "$(cat "$signalled")" "200" "$label idle disconnect after reconnecting" + assert_file_contains "$LOG_FILE" "User bob has been idle for 400 seconds." + rm -f "$SHIM_DIR/notify-send" "$SHIM_DIR/xprintidle" + + unset -f get_session_display get_session_xauthority ss ps kill id runuser + unset FAKE_SS FAKE_CONNECTED_SECONDS +} + +idle_disconnect_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 diff --git a/linux_host/tests/test_xrdp_startwm.sh b/linux_host/tests/test_xrdp_startwm.sh new file mode 100644 index 0000000..09b8887 --- /dev/null +++ b/linux_host/tests/test_xrdp_startwm.sh @@ -0,0 +1,887 @@ +#!/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 + /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) + +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 + 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 + + # 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" + 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. +# 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") + [ -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_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" + + 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_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 + 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)" "" +} + +# --------------------------------------------------------------------------- +# 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_turns_off_the_file_indexer +test_install_reads_sesman_ini_as_xrdp_does +test_install_refusals +test_ubuntu_on_xorg +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 +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 new file mode 100644 index 0000000..287a6b3 --- /dev/null +++ b/linux_host/xrdp-startwm.sh @@ -0,0 +1,663 @@ +#!/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. First, it unlocks the user's login keyring with the +# 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 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. + +# 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.2.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" +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. +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)" +} + +# 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 <