diff --git a/docs/linux-client-enrollment.md b/docs/linux-client-enrollment.md index 857486f..b3dbe70 100644 --- a/docs/linux-client-enrollment.md +++ b/docs/linux-client-enrollment.md @@ -1,6 +1,6 @@ # Enrolamiento de clientes Linux -El enrolador Linux incorpora una estación Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux a `lci.lasalle.mx` mediante `realmd`, `adcli`, Kerberos y SSSD. No instala el Credential Provider de Windows: Linux conserva su propio inicio de sesión PAM/SSSD. +El enrolador Linux incorpora una estación Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux a `lci.lasalle.mx` mediante `realmd`, `adcli`, Kerberos y SSSD. No instala el Credential Provider de Windows: Linux conserva su propio inicio de sesión PAM/SSSD. También instala RustDesk, lo dirige al servidor RustDesk autoalojado y registra el ID y la contraseña de acceso desatendido en el inventario protegido del controlador de dominio. La contraseña de la cuenta autorizada para unir equipos se solicita directamente por `realmd`. Nunca se acepta como argumento, ni se escribe en archivos, registros o la línea de comandos. @@ -44,9 +44,10 @@ de sesión de GDM también debe permanecer deshabilitado. 5. Activa SSSD, creación de directorio personal mediante PAM y valida la contraseña de la cuenta de equipo con `adcli testjoin`. En distribuciones que habilitan los respondedores NSS/PAM de SSSD tanto en `sssd.conf` como mediante sockets de systemd, desactiva los sockets duplicados para evitar una colisión al arrancar. 6. Cuando se proporcionó la NIC privada, activa actualizaciones DNS dinámicas de SSSD en esa interfaz. 7. Con `--enable-ssh`, instala y habilita OpenSSH y abre únicamente el servicio SSH cuando el firewall local ya está activo. -8. Registra `lightdm` y `cinnamon-screensaver` como inicios interactivos ante las políticas GPO de SSSD. En equipos con LightDM también muestra el ingreso manual, necesario para el primer acceso de un usuario del dominio. +8. Registra `lightdm` y `cinnamon-screensaver` como inicios interactivos ante las políticas GPO de SSSD. En equipos con LightDM oculta la lista de cuentas y conserva únicamente el ingreso manual: usuario y contraseña, necesario para el primer acceso de un usuario del dominio. 9. Con `--enable-hyperv-enhanced-session`, configura XRDP sobre Hyper-V sockets para que VMConnect pueda usar **Sesión mejorada**, repara certificados incompletos, registra `xrdp-sesman` en el mismo mapa interactivo y valida ambos servicios XRDP. 10. Instala el fondo azul, las fuentes Indivisa y un autoinicio compatible con Cinnamon, GNOME y XFCE. En cada sesión gráfica genera el saludo con el nombre del usuario y la ubicación/OU del equipo obtenidas de AD. +11. Instala RustDesk 1.4.9 desde el paquete oficial comprobado, configura exclusivamente el servidor institucional y crea una contraseña aleatoria de acceso desatendido. La contraseña no se muestra en Linux: viaja cifrada con la clave pública del controlador y éste la conserva mediante su inventario protegido. El objeto de equipo aparece como `NOMBRE` en `OU=Laboratorio`. SSSD registra su registro A cuando la actualización DNS dinámica está activada. @@ -64,9 +65,9 @@ El formato UPN explícito también permanece disponible: usuario@lci.lasalle.mx ``` -En Linux Mint, selecciona **Iniciar sesión** o el campo manual de usuario en -Slick Greeter y escribe la clave corta. El mosaico con el nombre de una cuenta -local no cambia de identidad al escribir únicamente otra contraseña. +En Linux Mint aparece únicamente el ingreso manual. Escribe la clave corta y +su contraseña; no se muestra una lista ni mosaicos de cuentas locales o del +dominio. La primera sesión crea `/home/usuario@lci.lasalle.mx`. El valor predeterminado de SSSD conserva credenciales para desconexiones breves de la red; las contraseñas no son administradas ni almacenadas por el Auth Broker. @@ -89,8 +90,25 @@ getent passwd 'usuario@lci.lasalle.mx' sudo sssctl domain-status lci.lasalle.mx sudo sssctl user-checks usuario -a acct -s lightdm sudo sssctl user-checks usuario -a acct -s cinnamon-screensaver +systemctl is-active rustdesk +sudo cat /var/lib/sgu/rustdesk/device.json ``` +En el controlador de dominio, el mismo inventario protegido usado por Windows +muestra el ID de un cliente Linux y, únicamente bajo solicitud explícita de un +administrador, su contraseña de RustDesk: + +```powershell +& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 +& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 ` + -ComputerName ALEX-LMINT -RevealPassword +``` + +Usa `--disable-rustdesk` sólo cuando una estación deba quedar expresamente sin +soporte remoto. `--rustdesk-registration-share` permite especificar el UNC del +controlador cuando una topología de DNS no puede resolver automáticamente el +controlador de dominio. + Para sacar un equipo del dominio de forma explícita: ```bash diff --git a/scripts/Enroll-SguLinuxDomainClient.sh b/scripts/Enroll-SguLinuxDomainClient.sh index 53c9ce9..bd1ad12 100755 --- a/scripts/Enroll-SguLinuxDomainClient.sh +++ b/scripts/Enroll-SguLinuxDomainClient.sh @@ -20,6 +20,8 @@ COMPUTER_NAME='' ALLOW_GROUP='' ENABLE_SSH=false ENABLE_HYPERV_ENHANCED_SESSION=false +ENABLE_RUSTDESK=true +RUSTDESK_REGISTRATION_SHARE='' usage() { cat <<'EOF' @@ -41,6 +43,9 @@ Options: --enable-ssh Install, enable, and (when active) permit OpenSSH in the local firewall. --enable-hyperv-enhanced-session Install and configure XRDP over Hyper-V sockets for VMConnect. + --disable-rustdesk Do not install the managed RustDesk remote-support client. + --rustdesk-registration-share UNC + Override the protected controller SMB enrollment share. --help Show this help. Network safety: @@ -75,6 +80,8 @@ while (($#)); do --allow-group) ALLOW_GROUP=${2:?Missing value for --allow-group}; shift 2 ;; --enable-ssh) ENABLE_SSH=true; shift ;; --enable-hyperv-enhanced-session) ENABLE_HYPERV_ENHANCED_SESSION=true; shift ;; + --disable-rustdesk) ENABLE_RUSTDESK=false; shift ;; + --rustdesk-registration-share) RUSTDESK_REGISTRATION_SHARE=${2:?Missing value for --rustdesk-registration-share}; shift 2 ;; --help|-h) usage; exit 0 ;; *) fail "Unknown argument: $1. Use --help for usage." ;; esac @@ -236,15 +243,16 @@ configure_graphical_domain_login() { rm -f "$temporary_sssd_configuration" rm -f "${sssd_configuration_directory}/91-sgu-xrdp.conf" - # Slick Greeter normally shows only the last/local account tile. Expose a - # manual user-name prompt so a first-time AD user can enter AL/AD/DO IDs. + # Do not disclose a list of local/domain accounts at the console. Slick + # Greeter still provides the explicit manual prompt needed for a first AD + # sign-in (AL/AD/DO identifier and password). if [[ -d /etc/lightdm/lightdm.conf.d ]]; then local temporary_lightdm_configuration temporary_lightdm_configuration=$(mktemp) printf '%s\n' \ '[Seat:*]' \ 'greeter-show-manual-login=true' \ - 'greeter-hide-users=false' >"$temporary_lightdm_configuration" + 'greeter-hide-users=true' >"$temporary_lightdm_configuration" install -o root -g root -m 644 "$temporary_lightdm_configuration" \ '/etc/lightdm/lightdm.conf.d/91-sgu-domain-login.conf' rm -f "$temporary_lightdm_configuration" @@ -397,6 +405,19 @@ EOF rm -f "$temporary_autostart" } +install_managed_rustdesk() { + [[ $ENABLE_RUSTDESK == true ]] || return 0 + local installer="${SCRIPT_DIRECTORY}/Install-SguLinuxRustDeskClient.sh" + if [[ ! -r $installer ]]; then + fail 'The managed Linux RustDesk installer is missing from this bootstrap package.' + fi + local -a parameters=(--domain-name "$DOMAIN_NAME") + if [[ -n $RUSTDESK_REGISTRATION_SHARE ]]; then + parameters+=(--registration-share "$RUSTDESK_REGISTRATION_SHARE") + fi + bash "$installer" "${parameters[@]}" +} + verify_domain_connectivity() { need_command getent getent ahostsv4 "$DOMAIN_CONTROLLER" >/dev/null || \ @@ -446,6 +467,7 @@ fi enable_ssh configure_hyperv_enhanced_session install_welcome_wallpaper +install_managed_rustdesk printf '\nLinux enrollment completed.\n' printf ' Host: %s\n' "$HOST_FQDN" @@ -453,4 +475,7 @@ printf ' Domain: %s\n' "$DOMAIN_NAME" printf ' OU: %s\n' "$COMPUTER_OU" printf ' Login format: %%U@%s\n' "$DOMAIN_NAME" printf ' Welcome wallpaper: generated at each graphical sign-in when the desktop is supported.\n' +if [[ $ENABLE_RUSTDESK == true ]]; then + printf ' RustDesk: configured and registered in the controller inventory.\n' +fi realm list diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index 9c5f688..655c501 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -353,7 +353,9 @@ foreach ($requiredPath in @( (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'), + (Join-Path $scriptsRoot 'Install-SguRustDeskLinuxEnrollment.ps1'), (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1'), + (Join-Path $scriptsRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1'), (Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'), (Join-Path $scriptsRoot 'Get-SguRustDeskDevice.ps1'), (Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'), @@ -643,10 +645,19 @@ $rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') ` -FirewallRemoteAddress $privateSubnet $rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk' New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null -foreach ($scriptName in @('Register-SguRustDeskDevice.ps1', 'Get-SguRustDeskDevice.ps1')) { +foreach ($scriptName in @( + 'Register-SguRustDeskDevice.ps1', + 'Get-SguRustDeskDevice.ps1', + 'Install-SguRustDeskLinuxEnrollment.ps1', + 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')) { Copy-Item -LiteralPath (Join-Path $scriptsRoot $scriptName) ` -Destination (Join-Path $rustDeskManagementRoot $scriptName) -Force } +$rustDeskLinuxEnrollment = & (Join-Path $rustDeskManagementRoot 'Install-SguRustDeskLinuxEnrollment.ps1') ` + -DomainName $DomainName ` + -ServerAddress $rustDeskDnsName ` + -ServerPublicKey $rustDeskServer.PublicKey ` + -ProcessorScriptPath (Join-Path $rustDeskManagementRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1') $rustDeskServerClient = & (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1') ` -ServerAddress $rustDeskDnsName ` -ServerPublicKey $rustDeskServer.PublicKey @@ -680,6 +691,7 @@ $validation = [ordered]@{ RustDeskHbbrTask = $rustDeskServer.HbbrTask RustDeskHbbsListening = $rustDeskServer.HbbsListening RustDeskHbbrListening = $rustDeskServer.HbbrListening + RustDeskLinuxRegistrationTask = (Get-ScheduledTask -TaskName $rustDeskLinuxEnrollment.RegistrationTask).State.ToString() RustDeskServerClientId = $rustDeskServerClient.RustDeskId EventCollector = (Get-Service Wecsvc).Status.ToString() EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring' @@ -698,6 +710,7 @@ if ($validation.BrokerService -ne 'Running' -or $validation.RemoteDesktop -ne 'Running' -or $validation.RustDeskHbbsTask -ne 'Running' -or $validation.RustDeskHbbrTask -ne 'Running' -or + $validation.RustDeskLinuxRegistrationTask -notin @('Ready', 'Running') -or -not $validation.RustDeskHbbsListening -or -not $validation.RustDeskHbbrListening -or $validation.EventCollector -ne 'Running' -or diff --git a/scripts/Install-SguLinuxRustDeskClient.sh b/scripts/Install-SguLinuxRustDeskClient.sh new file mode 100644 index 0000000..49d6631 --- /dev/null +++ b/scripts/Install-SguLinuxRustDeskClient.sh @@ -0,0 +1,279 @@ +#!/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" +} + +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 update + apt-get 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 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 <"$secret_path" + chmod 600 "$secret_path" + fi + + rustdesk --password "$ACCESS_PASSWORD" >/dev/null + RUSTDESK_ID=$(rustdesk --get-id 2>/dev/null | tail -n 1 | tr -d '[:space:]') + [[ $RUSTDESK_ID =~ ^[0-9]+$ ]] || fail "RustDesk returned an invalid device ID: $RUSTDESK_ID" +} + +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_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" diff --git a/scripts/Install-SguRustDeskLinuxEnrollment.ps1 b/scripts/Install-SguRustDeskLinuxEnrollment.ps1 new file mode 100644 index 0000000..bfc6d9a --- /dev/null +++ b/scripts/Install-SguRustDeskLinuxEnrollment.ps1 @@ -0,0 +1,128 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')] + [string]$DomainName, + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')] + [string]$ServerAddress, + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9+/=]+$')] + [string]$ServerPublicKey, + + [string]$RegistrationShareName = 'SGU-RustDesk-Enrollment$', + [string]$DataRoot = "$env:ProgramData\SGU\RustDesk\LinuxEnrollment", + [string]$ProcessorScriptPath = (Join-Path $PSScriptRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1') +) + +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Only a local administrator can install Linux RustDesk enrollment.' + } +} + +function Get-EnrollmentCertificate { + param([Parameter(Mandatory)][string]$FriendlyName) + + $certificate = Get-ChildItem -Path Cert:\LocalMachine\My | + Where-Object FriendlyName -eq $FriendlyName | + Where-Object HasPrivateKey | + Select-Object -First 1 + if (-not $certificate) { + $certificate = New-SelfSignedCertificate ` + -Subject 'CN=SGU RustDesk Linux enrollment' ` + -FriendlyName $FriendlyName ` + -CertStoreLocation 'Cert:\LocalMachine\My' ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -KeyUsage KeyEncipherment,DigitalSignature ` + -NotAfter (Get-Date).AddYears(5) + } + return $certificate +} + +function Set-EnrollmentDirectoryAcl { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$DomainNetbiosName + ) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + $arguments = @( + "`"$Path`"", '/inheritance:r', + '/grant:r', 'SYSTEM:(OI)(CI)(F)', + 'BUILTIN\Administrators:(OI)(CI)(F)', + "$DomainNetbiosName\Domain Computers:(OI)(CI)(M)" + ) + & icacls.exe @arguments | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not secure the Linux RustDesk enrollment directory $Path." + } +} + +Assert-Administrator +Import-Module ActiveDirectory -ErrorAction Stop +if (-not (Test-Path -LiteralPath $ProcessorScriptPath -PathType Leaf)) { + throw "The Linux RustDesk registration processor is missing: $ProcessorScriptPath" +} +if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the protected Linux RustDesk enrollment endpoint')) { + return +} + +$domain = Get-ADDomain -Identity $DomainName +$publicRoot = Join-Path $DataRoot 'Public' +$requestsRoot = Join-Path $publicRoot 'Requests' +$archiveRoot = Join-Path $publicRoot 'Archive' +$rejectedRoot = Join-Path $publicRoot 'Rejected' +foreach ($path in @($DataRoot, $publicRoot, $requestsRoot, $archiveRoot, $rejectedRoot)) { + Set-EnrollmentDirectoryAcl -Path $path -DomainNetbiosName $domain.NetBIOSName +} + +$certificate = Get-EnrollmentCertificate -FriendlyName 'SGU RustDesk Linux enrollment' +$publicCertificatePath = Join-Path $publicRoot 'registration-public.cer' +Export-Certificate -Cert $certificate -FilePath $publicCertificatePath -Force | Out-Null +$clientConfiguration = [ordered]@{ + ServerAddress = $ServerAddress + ServerPublicKey = $ServerPublicKey + RegistrationShare = "\\$env:COMPUTERNAME\$RegistrationShareName" + UpdatedAt = (Get-Date).ToString('o') +} +[IO.File]::WriteAllText((Join-Path $publicRoot 'rustdesk-client.json'), + ($clientConfiguration | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) + +$share = Get-SmbShare -Name $RegistrationShareName -ErrorAction SilentlyContinue +if (-not $share) { + New-SmbShare -Name $RegistrationShareName -Path $publicRoot ` + -FullAccess @('SYSTEM', 'BUILTIN\Administrators') ` + -ChangeAccess "$($domain.NetBIOSName)\Domain Computers" | Out-Null +} +elseif ($share.Path -ne $publicRoot) { + throw "The existing SMB share $RegistrationShareName points to $($share.Path), not $publicRoot." +} + +$installedProcessor = Join-Path $DataRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1' +Copy-Item -LiteralPath $ProcessorScriptPath -Destination $installedProcessor -Force +$processorArguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$installedProcessor`" -DataRoot `"$DataRoot`" -CertificateThumbprint $($certificate.Thumbprint)" +$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -Argument $processorArguments +$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 3650) +$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +Register-ScheduledTask -TaskName 'SGU-RustDesk-LinuxRegistration' -Action $action -Trigger $trigger ` + -Principal $principal -Description 'Registers encrypted RustDesk credentials sent by domain-joined Linux computers.' -Force | Out-Null + +New-NetFirewallRule -DisplayName 'SGU RustDesk Linux enrollment SMB' -Group 'SGU RustDesk' ` + -Direction Inbound -Action Allow -Protocol TCP -LocalPort 445 -Profile Domain -ErrorAction SilentlyContinue | Out-Null + +[pscustomobject]@{ + RegistrationShare = "\\$env:COMPUTERNAME\$RegistrationShareName" + PublicCertificatePath = $publicCertificatePath + RegistrationTask = 'SGU-RustDesk-LinuxRegistration' + CertificateThumbprint = $certificate.Thumbprint +} diff --git a/scripts/Invoke-SguRustDeskLinuxRegistrationProcessor.ps1 b/scripts/Invoke-SguRustDeskLinuxRegistrationProcessor.ps1 new file mode 100644 index 0000000..3207a29 --- /dev/null +++ b/scripts/Invoke-SguRustDeskLinuxRegistrationProcessor.ps1 @@ -0,0 +1,90 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$DataRoot, + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Fa-f0-9]{40}$')] + [string]$CertificateThumbprint +) + +$ErrorActionPreference = 'Stop' +$requestsRoot = Join-Path $DataRoot 'Public\Requests' +$archiveRoot = Join-Path $DataRoot 'Public\Archive' +$rejectedRoot = Join-Path $DataRoot 'Public\Rejected' +$registrationScript = Join-Path $env:ProgramData 'SGU\RustDesk\Register-SguRustDeskDevice.ps1' + +function Write-Result { + param( + [Parameter(Mandatory)][string]$RequestId, + [Parameter(Mandatory)][hashtable]$Value + ) + $path = Join-Path $requestsRoot "$RequestId.result.json" + [IO.File]::WriteAllText($path, ($Value | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) +} + +function Get-ComputerNameFromOwner { + param([Parameter(Mandatory)][string]$Owner) + if ($Owner -notmatch '^[^\\]+\\(?[A-Za-z0-9][A-Za-z0-9-]{0,62})\$$') { + throw 'The request file owner is not an Active Directory computer account.' + } + return $Matches.Name.ToUpperInvariant() +} + +if (-not (Test-Path -LiteralPath $registrationScript -PathType Leaf)) { + throw "The RustDesk inventory registration script is missing: $registrationScript" +} +Import-Module ActiveDirectory -ErrorAction Stop +$certificate = Get-Item -LiteralPath "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop +$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate) +if (-not $rsa) { + throw 'The Linux RustDesk enrollment certificate does not have an RSA private key.' +} + +New-Item -ItemType Directory -Path $requestsRoot, $archiveRoot, $rejectedRoot -Force | Out-Null +Get-ChildItem -LiteralPath $requestsRoot -Filter '*.request' -File | ForEach-Object { + $requestFile = $_ + $requestIdMatch = [regex]::Match($requestFile.BaseName, + '(?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$') + if (-not $requestIdMatch.Success) { + Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force + return + } + $requestId = $requestIdMatch.Groups['Id'].Value + try { + $ownerComputerName = Get-ComputerNameFromOwner -Owner (Get-Acl -LiteralPath $requestFile.FullName).Owner + $plainText = [Text.Encoding]::UTF8.GetString($rsa.Decrypt( + [IO.File]::ReadAllBytes($requestFile.FullName), + [Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)) + $request = $plainText | ConvertFrom-Json -ErrorAction Stop + $computerName = [string]$request.ComputerName + $rustDeskId = [string]$request.RustDeskId + $accessPassword = [string]$request.AccessPassword + $declaredRequestId = [string]$request.RequestId + if ($computerName -notmatch '^[A-Za-z0-9][A-Za-z0-9-]{0,62}$' -or + $computerName.ToUpperInvariant() -ne $ownerComputerName -or + $rustDeskId -notmatch '^\d+$' -or + $accessPassword.Length -lt 12 -or + $declaredRequestId -notmatch '^[0-9a-fA-F-]{36}$') { + throw 'The encrypted Linux RustDesk registration payload is invalid.' + } + Get-ADComputer -Identity $ownerComputerName -ErrorAction Stop | Out-Null + & $registrationScript -ComputerName $ownerComputerName -RustDeskId $rustDeskId ` + -AccessPassword $accessPassword -Confirm:$false | Out-Null + Write-Result -RequestId $declaredRequestId -Value @{ + Status = 'Registered' + ComputerName = $ownerComputerName + RustDeskId = $rustDeskId + RegisteredAt = (Get-Date).ToString('o') + } + Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $archiveRoot $requestFile.Name) -Force + } + catch { + $safeError = $_.Exception.Message -replace '(?i)password[^\r\n]*', 'credential validation failed' + Write-Result -RequestId $requestId -Value @{ + Status = 'Rejected' + Error = $safeError + } + Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index 2ae0151..54e75fe 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -143,6 +143,8 @@ Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip ` # Linux administrator never receives Windows binaries or certificate material. Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Enroll-SguLinuxDomainClient.sh') ` -Destination (Join-Path $linuxClientRoot 'Enroll-SguLinuxDomainClient.sh') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguLinuxRustDeskClient.sh') ` + -Destination (Join-Path $linuxClientRoot 'Install-SguLinuxRustDeskClient.sh') Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\linux-client-enrollment.md') ` -Destination (Join-Path $linuxClientRoot 'README.md') Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Set-SguWelcomeWallpaper.sh') ` @@ -168,7 +170,9 @@ $serverScripts = @( 'Get-SguBrokerLog.ps1', 'Install-SguDomainMonitoring.ps1', 'Install-SguRustDeskClient.ps1', + 'Install-SguRustDeskLinuxEnrollment.ps1', 'Install-SguRustDeskServer.ps1', + 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1', 'Invoke-SguMonitoringMaintenance.ps1', 'New-LabCertificate.ps1', 'Get-SguRustDeskDevice.ps1',