Files
SGU-CredentialProvider/scripts/Install-SguLinuxRustDeskClient.sh
T

428 lines
16 KiB
Bash

#!/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 <<EOF
rendezvous_server = '${RUSTDESK_SERVER_ADDRESS}:21116'
nat_type = 1
serial = 0
[options]
custom-rendezvous-server = '${RUSTDESK_SERVER_ADDRESS}:21116'
relay-server = '${RUSTDESK_SERVER_ADDRESS}:21117'
key = '${RUSTDESK_SERVER_PUBLIC_KEY}'
verification-method = 'use-permanent-password'
approve-mode = 'password'
EOF
)
# The service starts as root but RustDesk hands its graphical server to the
# LightDM session account. Configure both profiles; writing only root's
# profile leaves the greeter-side server using a temporary password.
install -d -o root -g root -m 700 /root/.config/rustdesk /etc/rustdesk
printf '%s\n' "$configuration" | install -o root -g root -m 600 /dev/stdin \
/root/.config/rustdesk/RustDesk2.toml
printf '%s\n' "$configuration" | install -o root -g root -m 644 /dev/stdin \
/etc/rustdesk/RustDesk2.toml
if id lightdm >/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)
# Ubuntu exposes the unit as gdm.service on some releases while
# the package still reads /etc/gdm3/custom.conf. Prefer the
# distribution-specific directory instead of inferring it only
# from the unit name.
if [[ -d /etc/gdm3 || -f /etc/gdm3/custom.conf ]]; 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"