424 lines
16 KiB
Bash
424 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)
|
|
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"
|