#!/usr/bin/env bash # Install-SguLinuxRustDeskClient.sh # # Installs/configures a RustDesk client on an AD-joined Linux workstation and # registers its randomly generated unattended-access credential with the # protected inventory on the SGU domain controller. The credential is never # emitted to stdout and is sent to the controller only in an RSA-OAEP envelope. set -Eeuo pipefail IFS=$'\n\t' DOMAIN_NAME='lci.lasalle.mx' REGISTRATION_SHARE='' STATE_ROOT='/var/lib/sgu/rustdesk' CLIENT_VERSION='1.4.9' DOWNLOAD_URI='https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.deb' EXPECTED_SHA256='7244BA47C40E804172044BFBE659467C54CE46554C98E78C8C0406F1D612FDA3' usage() { cat <<'EOF' Usage: sudo ./Install-SguLinuxRustDeskClient.sh [options] Options: --domain-name VALUE AD DNS domain (default: lci.lasalle.mx). --registration-share UNC SMB enrollment share. Defaults to the first AD domain controller's SGU RustDesk share. --state-root PATH Root-owned local RustDesk state directory. --help Show this help. The computer must already be joined to Active Directory. The script uses the machine keytab to authenticate to the enrollment share, configures the self-hosted RustDesk server, creates an unattended-access password, and waits for the controller to confirm protected inventory registration. EOF } fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } need_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command is unavailable: $1" } apt_get_with_retry() { local attempt for attempt in $(seq 1 60); do if apt-get "$@"; then return 0 fi if fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock \ >/dev/null 2>&1; then printf 'Waiting for another package operation before retrying apt-get %s.\n' "$1" >&2 sleep 5 continue fi fail "apt-get $1 failed for a reason other than a temporary package lock." done fail 'Timed out waiting for another package operation to finish.' } while (($#)); do case "$1" in --domain-name) DOMAIN_NAME=${2:?Missing value for --domain-name}; shift 2 ;; --registration-share) REGISTRATION_SHARE=${2:?Missing value for --registration-share}; shift 2 ;; --state-root) STATE_ROOT=${2:?Missing value for --state-root}; shift 2 ;; --help|-h) usage; exit 0 ;; *) fail "Unknown argument: $1. Use --help for usage." ;; esac done [[ ${EUID} -eq 0 ]] || fail 'Run this command with sudo or as root.' [[ -r /etc/krb5.keytab ]] || fail 'The AD machine keytab is missing. Join the computer to the domain first.' install_prerequisites() { if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt_get_with_retry update apt_get_with_retry install -y curl openssl smbclient dnsutils return fi if command -v dnf >/dev/null 2>&1; then dnf install -y curl openssl samba-client bind-utils return fi fail 'RustDesk enrollment supports apt-get (Debian/Ubuntu) and dnf (RHEL/Fedora/Rocky/AlmaLinux).' } resolve_registration_share() { if [[ -n $REGISTRATION_SHARE ]]; then return fi local controller controller=$(host -t SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null | awk '/SRV record/ { print $NF; exit }' | sed 's/\.$//') [[ -n $controller ]] || controller=$DOMAIN_NAME REGISTRATION_SHARE="//${controller}/SGU-RustDesk-Enrollment$" } initialize_machine_kerberos() { local principal # adcli places the machine-account principal in the keytab. Prefer it to # host/FQDN: some AD deployments retain the latter locally even when its # SPN is not accepted by the KDC for an initial ticket request. principal=$(klist -k /etc/krb5.keytab 2>/dev/null | awk '$NF ~ /^[^/@]+\$@/ { print $NF; exit }') if [[ -z $principal ]]; then principal=$(klist -k /etc/krb5.keytab 2>/dev/null | awk '$NF ~ /^host\// { print $NF; exit }') fi [[ -n $principal ]] || fail 'No host principal was found in /etc/krb5.keytab.' KRB5CCNAME="FILE:${STATE_ROOT}/machine-krb5cc" export KRB5CCNAME rm -f -- "${KRB5CCNAME#FILE:}" kinit -k -t /etc/krb5.keytab "$principal" } smb_get() { local remote_name=$1 local local_path=$2 smbclient --use-kerberos=required -N "$REGISTRATION_SHARE" \ -c "get ${remote_name} ${local_path}" >/dev/null } smb_put() { local local_path=$1 local remote_name=$2 smbclient --use-kerberos=required -N "$REGISTRATION_SHARE" \ -c "put ${local_path} ${remote_name}" >/dev/null } install_rustdesk() { local installer_path="${STATE_ROOT}/rustdesk-${CLIENT_VERSION}-x86_64.deb" local installed_version='' if command -v rustdesk >/dev/null 2>&1; then installed_version=$(rustdesk --version 2>/dev/null | head -n 1 || true) fi if [[ $installed_version != *"${CLIENT_VERSION}"* ]]; then curl --fail --location --proto '=https' --tlsv1.2 \ --output "$installer_path" "$DOWNLOAD_URI" local actual_hash actual_hash=$(sha256sum "$installer_path" | awk '{ print toupper($1) }') [[ $actual_hash == "$EXPECTED_SHA256" ]] || fail 'RustDesk package SHA-256 verification failed.' if command -v apt-get >/dev/null 2>&1; then dpkg -i "$installer_path" || apt_get_with_retry install -f -y else fail 'The pinned RustDesk package is currently provided as a Debian package only.' fi fi need_command rustdesk systemctl enable rustdesk } read_server_configuration() { local configuration_path="${STATE_ROOT}/rustdesk-client.json" smb_get 'rustdesk-client.json' "$configuration_path" RUSTDESK_SERVER_ADDRESS=$(python3 - "$configuration_path" <<'PY' import json import sys with open(sys.argv[1], encoding='utf-8') as source: value = json.load(source) address = value.get('ServerAddress', '') key = value.get('ServerPublicKey', '') if not isinstance(address, str) or not isinstance(key, str) or not address or not key: raise SystemExit('The controller RustDesk configuration is incomplete.') print(address) PY ) RUSTDESK_SERVER_PUBLIC_KEY=$(python3 - "$configuration_path" <<'PY' import json import sys with open(sys.argv[1], encoding='utf-8') as source: print(json.load(source)['ServerPublicKey']) PY ) } configure_rustdesk() { local configuration configuration=$(cat </dev/null 2>&1; then install -d -o lightdm -g lightdm -m 700 /var/lib/lightdm/.config/rustdesk printf '%s\n' "$configuration" | install -o lightdm -g lightdm -m 600 /dev/stdin \ /var/lib/lightdm/.config/rustdesk/RustDesk2.toml fi systemctl restart rustdesk systemctl is-active --quiet rustdesk || fail 'The RustDesk service did not start.' wait_for_rustdesk_server } configure_x11_login_screen() { local display_manager='' local configuration_changed=false local configuration_path='' local temporary_configuration='' if [[ -L /etc/systemd/system/display-manager.service ]]; then display_manager=$(basename "$(readlink -f /etc/systemd/system/display-manager.service)") fi case "$display_manager" in gdm3.service|gdm.service) if [[ $display_manager == gdm3.service ]]; then configuration_path='/etc/gdm3/custom.conf' else configuration_path='/etc/gdm/custom.conf' fi install -d -o root -g root -m 755 "$(dirname "$configuration_path")" [[ -f $configuration_path ]] || printf '[daemon]\n' >"$configuration_path" temporary_configuration=$(mktemp) python3 - "$configuration_path" "$temporary_configuration" <<'PY' import re import sys from pathlib import Path source = Path(sys.argv[1]) destination = Path(sys.argv[2]) lines = source.read_text(encoding='utf-8').splitlines() daemon_start = None daemon_end = len(lines) for index, line in enumerate(lines): if re.match(r'^\s*\[daemon\]\s*$', line, re.IGNORECASE): daemon_start = index continue if daemon_start is not None and index > daemon_start and re.match(r'^\s*\[[^]]+\]\s*$', line): daemon_end = index break if daemon_start is None: if lines and lines[-1]: lines.append('') lines.extend(['[daemon]', 'WaylandEnable=false']) else: setting = re.compile(r'^\s*[#;]?\s*WaylandEnable\s*=.*$', re.IGNORECASE) for index in range(daemon_start + 1, daemon_end): if setting.match(lines[index]): lines[index] = 'WaylandEnable=false' break else: lines.insert(daemon_end, 'WaylandEnable=false') destination.write_text('\n'.join(lines) + '\n', encoding='utf-8') PY if ! cmp -s "$temporary_configuration" "$configuration_path"; then install -o root -g root -m 644 "$temporary_configuration" "$configuration_path" configuration_changed=true fi rm -f "$temporary_configuration" ;; sddm.service) configuration_path='/etc/sddm.conf.d/91-sgu-rustdesk-x11.conf' install -d -o root -g root -m 755 "$(dirname "$configuration_path")" temporary_configuration=$(mktemp) printf '%s\n' '[General]' 'DisplayServer=x11' >"$temporary_configuration" if ! cmp -s "$temporary_configuration" "$configuration_path"; then install -o root -g root -m 644 "$temporary_configuration" "$configuration_path" configuration_changed=true fi rm -f "$temporary_configuration" ;; lightdm.service) # LightDM's greeter already runs on X11, which RustDesk supports. ;; *) printf 'WARNING: Could not identify a supported display manager; RustDesk login-screen access may require X11 configuration.\n' >&2 ;; esac if [[ $configuration_changed == true ]]; then printf 'RustDesk login-screen support was configured for X11; reboot after enrollment to activate it.\n' fi } wait_for_rustdesk_server() { local attempt local candidate_id # `systemctl is-active` only confirms that the launcher is alive. On Linux # it still needs to start the `--server` process for the greeter account. # Calling `rustdesk --password` during that short window returns successfully # but does not persist a password for the remote-access process. for attempt in $(seq 1 20); do if systemctl is-active --quiet rustdesk \ && pgrep -f '/usr/share/rustdesk/rustdesk --server' >/dev/null 2>&1; then candidate_id=$(rustdesk --get-id 2>/dev/null | tail -n 1 | tr -d '[:space:]') if [[ $candidate_id =~ ^[0-9]+$ ]]; then RUSTDESK_ID=$candidate_id return fi fi sleep 1 done fail 'The RustDesk greeter-side server did not become ready within 20 seconds.' } set_access_password() { local secret_path="${STATE_ROOT}/access.secret" if [[ -r $secret_path ]] && [[ $(wc -c <"$secret_path") -le 32 ]]; then ACCESS_PASSWORD=$(<"$secret_path") else # RustDesk's permanent-password UI is reliable with a short, printable # credential. Earlier Linux enrollment generated 48 hexadecimal # characters; rotate that legacy value to a 24-character password. ACCESS_PASSWORD="Sgu-$(openssl rand -hex 10)" umask 077 printf '%s' "$ACCESS_PASSWORD" >"$secret_path" chmod 600 "$secret_path" fi local password_result wait_for_rustdesk_server password_result=$(rustdesk --password "$ACCESS_PASSWORD" 2>&1) \ || fail "RustDesk rejected the permanent password update: $password_result" [[ $password_result == *Done!* ]] \ || fail "RustDesk did not acknowledge the permanent password update: $password_result" rustdesk --option verification-method use-permanent-password >/dev/null rustdesk --option approve-mode password >/dev/null systemctl restart rustdesk systemctl is-active --quiet rustdesk || fail 'The RustDesk service did not restart after setting its permanent password.' wait_for_rustdesk_server } register_with_controller() { local certificate_path="${STATE_ROOT}/registration-public.cer" local public_key_path="${STATE_ROOT}/registration-public.pem" local request_path="${STATE_ROOT}/registration.request" local encrypted_request_path="${STATE_ROOT}/registration.request.enc" local result_path="${STATE_ROOT}/registration.result.json" local request_id request_id=$(cat /proc/sys/kernel/random/uuid) local computer_name computer_name=$(hostname -s | tr '[:lower:]' '[:upper:]') [[ $computer_name =~ ^[A-Z0-9][A-Z0-9-]{0,62}$ ]] || fail 'The Linux computer name is not valid for RustDesk inventory.' smb_get 'registration-public.cer' "$certificate_path" openssl x509 -inform DER -in "$certificate_path" -pubkey -noout >"$public_key_path" chmod 600 "$public_key_path" # AccessPassword is hexadecimal and the other values are constrained, so # this compact JSON is safe to construct without echoing sensitive data. printf '{"ComputerName":"%s","RustDeskId":"%s","AccessPassword":"%s","RequestId":"%s"}' \ "$computer_name" "$RUSTDESK_ID" "$ACCESS_PASSWORD" "$request_id" >"$request_path" openssl pkeyutl -encrypt -pubin -inkey "$public_key_path" \ -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \ -in "$request_path" -out "$encrypted_request_path" chmod 600 "$request_path" "$encrypted_request_path" local remote_request="${computer_name}-${request_id}.request" smb_put "$encrypted_request_path" "Requests/${remote_request}" local attempt=0 while ((attempt < 18)); do rm -f -- "$result_path" if smb_get "Requests/${request_id}.result.json" "$result_path" 2>/dev/null; then python3 - "$result_path" "$computer_name" "$RUSTDESK_ID" <<'PY' import json import sys with open(sys.argv[1], encoding='utf-8') as source: result = json.load(source) if result.get('Status') != 'Registered': raise SystemExit(result.get('Error', 'The controller rejected the RustDesk registration.')) if result.get('ComputerName') != sys.argv[2] or result.get('RustDeskId') != sys.argv[3]: raise SystemExit('The controller response did not match this computer or RustDesk ID.') PY rm -f -- "$request_path" "$encrypted_request_path" "$public_key_path" "$certificate_path" "$result_path" return fi sleep 5 ((attempt+=1)) done fail 'RustDesk was configured locally, but the domain controller did not confirm inventory registration within 90 seconds.' } install -d -o root -g root -m 700 "$STATE_ROOT" trap 'if [[ -n ${KRB5CCNAME:-} ]]; then rm -f -- "${KRB5CCNAME#FILE:}"; fi' EXIT install_prerequisites resolve_registration_share initialize_machine_kerberos install_rustdesk read_server_configuration configure_x11_login_screen configure_rustdesk set_access_password register_with_controller device_path="${STATE_ROOT}/device.json" printf '{"ComputerName":"%s","RustDeskId":"%s","ServerAddress":"%s","ConfiguredAt":"%s"}\n' \ "$(hostname -s | tr '[:lower:]' '[:upper:]')" "$RUSTDESK_ID" "$RUSTDESK_SERVER_ADDRESS" \ "$(date --iso-8601=seconds)" >"$device_path" chmod 600 "$device_path" printf 'RustDesk enrollment completed. ID: %s\n' "$RUSTDESK_ID"