#!/usr/bin/env bash # Run as root on a Proxmox host. No credentials or site-specific addresses here. set -Eeuo pipefail set +x umask 077 fail() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } say() { printf '%s\n' "$*"; } UI_TOOL=''; UI_HEIGHT=20; UI_WIDTH=76 init_ui() { local dimensions rows=24 columns=80 UI_TOOL='' if command -v whiptail >/dev/null 2>&1; then UI_TOOL=whiptail elif command -v dialog >/dev/null 2>&1; then UI_TOOL=dialog; fi dimensions=$(stty size <&3 2>/dev/null) || dimensions='' if [[ $dimensions =~ ^([0-9]+)[[:space:]]+([0-9]+)$ ]]; then rows=${BASH_REMATCH[1]}; columns=${BASH_REMATCH[2]} fi if [[ -z $UI_TOOL || ${TERM:-dumb} == dumb || $rows -lt 16 || $columns -lt 60 ]]; then UI_TOOL='' say 'Dialogoberflaeche nicht verfuegbar (whiptail/dialog oder ausreichend grosses Terminal fehlt); Texteingabe verwenden.' return 0 fi UI_HEIGHT=$((rows-4)); UI_WIDTH=$((columns-4)) if [[ $UI_HEIGHT -gt 30 ]]; then UI_HEIGHT=30; fi if [[ $UI_WIDTH -gt 110 ]]; then UI_WIDTH=110; fi } ui_capture() { # The UI uses the controlling terminal, even if this script's stdin is a pipe. # Secrets/selections are captured on a separate descriptor, never stdout/logs. REPLY=$("$UI_TOOL" --output-fd 4 "$@" 4>&1 1>&3 2>&3 <&3) } ask() { if [[ -n $UI_TOOL ]]; then ui_capture --title 'LXC Enrollment - Eingabe' --ok-button 'Weiter' --cancel-button 'Abbrechen' \ --inputbox "$1" "$UI_HEIGHT" "$UI_WIDTH" "${2:-}" || fail 'Eingabe abgebrochen oder Dialog fehlgeschlagen.' else printf '%s ' "$1" >&2 IFS= read -r REPLY <&3 || fail 'Eingabe abgebrochen.' fi } secret() { if [[ -n $UI_TOOL ]]; then ui_capture --title 'LXC Enrollment - Verdeckte Eingabe' --ok-button 'Weiter' --cancel-button 'Abbrechen' \ --passwordbox "$1" "$UI_HEIGHT" "$UI_WIDTH" || fail 'Eingabe abgebrochen oder Dialog fehlgeschlagen.' return 0 fi printf '%s ' "$1" >&2 IFS= read -r -s REPLY <&3 || fail 'Eingabe abgebrochen.' printf '\n' >&2 } installed_label() { if [[ $1 == 1 ]]; then printf 'vorhanden'; else printf 'fehlt'; fi; } choice_hint() { if [[ $1 == 1 ]]; then printf '[J/n]'; else printf '[j/N]'; fi; } yesno_label() { if [[ $1 == 1 ]]; then printf 'Ja'; else printf 'Nein'; fi; } LIST=0; DRY=0; MANAGER=''; VERSION=''; LOGIN_SERVER=''; SELECTION=''; VERSION_EXPLICIT=0 SSH_ENABLED=1; SSH_USER=''; SSH_SUDO=0; SSH_NOPASSWD=0; SAVE_SETTINGS=1 TS_ACCEPT_DNS=0; DNS_SAVED=0; RESUME_TS=0 VARS_FILE=/etc/lxc-enroll/.vars while [[ $# -gt 0 ]]; do case "$1" in --list) LIST=1; shift ;; --dry-run) DRY=1; shift ;; --resume-tailscale) RESUME_TS=1; shift ;; --manager|--agent-version|--login-server|--select) [[ $# -ge 2 && -n $2 ]] || fail "Wert fehlt: $1" case "$1" in --manager) MANAGER=$2;; --agent-version) VERSION=$2; VERSION_EXPLICIT=1;; --login-server) LOGIN_SERVER=$2;; --select) SELECTION=$2;; esac shift 2 ;; --help|-h) cat <<'HELP' Auf dem Proxmox-Host als root starten: bash lxc-enroll.sh bash lxc-enroll.sh --list bash lxc-enroll.sh --dry-run --select '106 107' Optionen: --manager IP/NAME --agent-version VERSION --login-server HTTPS-URL --select 'CTID CTID' --list --dry-run --resume-tailscale --resume-tailscale: Bereits installiertes Tailscale nur bei NeedsLogin/NoState anmelden. Bestehende Registrierungen werden nicht ersetzt. Nur laufende Debian-/Ubuntu-LXCs mit systemd. Vorhandene Tailscale-/Wazuh- Registrierungen bleiben unveraendert. Optional Tailscale SSH/Nutzer/Sudo fuer die Auswahl. Lokale Einstellungen: /etc/lxc-enroll/.vars (JSON, ohne Geheimnisse). Keine Host-Agent-Installation. Geheimnisse werden lokal abgefragt. Dialogoberflaeche fuer Auswahl, Eingaben und Geheimnisse (whiptail/dialog). LXC-Checkliste: Leertaste markiert. Ohne Dialogwerkzeug: Texteingabe. HELP exit 0 ;; *) fail "Unbekannte Option: $1" ;; esac done [[ $EUID == 0 ]] || fail 'Bitte auf dem Proxmox-Host als root starten.' for tool in pct timeout python3; do command -v "$tool" >/dev/null || fail "$tool fehlt auf dem Host."; done declare -a IDS=() NAMES=() TS=() WZ=() TUN=() MANAGERS=() SELECTED=() REMOTE_DIRS=() TMP='' cleanup() { local id dir for id in "${SELECTED[@]}"; do dir=${REMOTE_DIRS[$id]:-} if [[ $dir =~ ^/run/lxc-enroll\.[A-Za-z0-9]+$ ]]; then timeout 15 pct exec "$id" -- rm -rf -- "$dir" >/dev/null 2>&1 || true fi done [[ -z $TMP ]] || rm -rf -- "$TMP" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM yes_no() { local prompt=$1 default=$2 rc local -a options=() if [[ -n ${UI_TOOL:-} ]]; then [[ $default != 0 ]] || options+=(--defaultno) # Keep keyboard hints only in text mode; buttons show Ja/Nein in the UI. prompt=${prompt% \[J/n\]}; prompt=${prompt% \[j/N\]} if ui_capture --title 'LXC Enrollment - Auswahl' --yes-button 'Ja' --no-button 'Nein' \ "${options[@]}" --yesno "$prompt" "$UI_HEIGHT" "$UI_WIDTH"; then REPLY=1 else rc=$? case "$rc" in 1) REPLY=0;; *) fail 'Auswahl abgebrochen oder Dialog fehlgeschlagen.';; esac fi return 0 fi ask "$prompt" case "$REPLY" in '' ) REPLY=$default;; 1|j|J|ja|Ja|y|Y|yes|Yes) REPLY=1;; 0|n|N|nein|Nein|no|No) REPLY=0;; *) fail 'Bitte j oder n eingeben (auch 1/0 moeglich).';; esac } load_settings() { local fields key value [[ ! -e $VARS_FILE && ! -L $VARS_FILE ]] && return 0 yes_no 'Gespeicherte Einstellungen aus /etc/lxc-enroll/.vars verwenden? [J/n]' 1 [[ $REPLY == 1 ]] || return 0 # JSON data, never source/eval. Reject non-root-owned or writable settings. fields=$(python3 - "$VARS_FILE" <<'PY' import json, os, stat, sys p=sys.argv[1]; s=os.lstat(p) if not stat.S_ISREG(s.st_mode) or s.st_uid != 0 or stat.S_IMODE(s.st_mode) & 0o077: sys.exit('Unsichere Rechte der .vars: regulaere Datei, root, Modus 600 erforderlich.') d=json.load(open(p)) for k in ('manager','agent_version','login_server','ssh_enabled','ssh_user','ssh_sudo','ssh_nopasswd','ts_accept_dns'): if k not in d: continue v=d.get(k,'') if not isinstance(v,str) or any(c in v for c in '\n\r\t\0') or len(v)>16384: sys.exit('Ungueltige .vars.') print(k+'\t'+v) PY ) || fail 'Gespeicherte Einstellungen konnten nicht sicher gelesen werden.' while IFS=$'\t' read -r key value; do case "$key" in manager) [[ -n $MANAGER ]] || MANAGER=$value;; agent_version) [[ -n $VERSION ]] || VERSION=$value;; login_server) [[ -n $LOGIN_SERVER ]] || LOGIN_SERVER=$value;; ssh_enabled) [[ $value =~ ^[01]$ ]] || fail 'Ungueltige SSH-Einstellung.'; SSH_ENABLED=$value;; ssh_user) SSH_USER=$value;; ssh_sudo) [[ $value =~ ^[01]$ ]] || fail 'Ungueltige Sudo-Einstellung.'; SSH_SUDO=$value;; ssh_nopasswd) [[ $value =~ ^[01]$ ]] || fail 'Ungueltige Sudo-Einstellung.'; SSH_NOPASSWD=$value;; ts_accept_dns) [[ $value =~ ^[01]$ ]] || fail 'Ungueltige DNS-Einstellung.'; TS_ACCEPT_DNS=$value; DNS_SAVED=1;; esac done <<<"$fields" } save_settings() { [[ $SAVE_SETTINGS == 1 ]] || return 0 install -d -m 700 /etc/lxc-enroll python3 - "$VARS_FILE" "$MANAGER" "$VERSION" "$LOGIN_SERVER" "$SSH_ENABLED" "$SSH_USER" "$SSH_SUDO" "$SSH_NOPASSWD" "$TS_ACCEPT_DNS" <<'PY' import json, os, sys, tempfile p=sys.argv[1] keys=('manager','agent_version','login_server','ssh_enabled','ssh_user','ssh_sudo','ssh_nopasswd','ts_accept_dns') fd,tmp=tempfile.mkstemp(prefix='.vars.',dir=os.path.dirname(p)) try: os.fchmod(fd,0o600) with os.fdopen(fd,'w') as f: json.dump(dict(zip(keys,sys.argv[2:])),f,indent=2); f.write('\n') os.replace(tmp,p) finally: if os.path.exists(tmp): os.unlink(tmp) PY say 'Einstellungen ohne Geheimnisse gespeichert: /etc/lxc-enroll/.vars' } scan() { local rows id status rest config meta name os codename ts wz mgr tun init rows=$(pct list) || fail 'Containerliste konnte nicht gelesen werden.' while read -r id status rest; do [[ $id =~ ^[1-9][0-9]*$ ]] || continue if [[ $status != running ]]; then say "Uebersprungen: LXC $id ($status)."; continue; fi config=$(pct config "$id") || { say "Uebersprungen: LXC $id (Konfiguration nicht lesbar)."; continue; } if grep -q '^lock:' <<<"$config"; then say "Uebersprungen: LXC $id (gesperrt)."; continue; fi meta=$(timeout 20 pct exec "$id" -- sh -s <<'PROBE' set -eu is_installed() { [ "$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null || true)" = 'install ok installed' ]; } . /etc/os-release ts=0; wz=0; mgr=0; tun=0; init=0 if command -v tailscale >/dev/null 2>&1 || is_installed tailscale; then ts=1; fi if [ -x /var/ossec/bin/wazuh-agentd ] || is_installed wazuh-agent; then wz=1; fi if [ -x /var/ossec/bin/wazuh-managerd ] || is_installed wazuh-manager; then mgr=1; wz=1; fi [ ! -c /dev/net/tun ] || tun=1 [ ! -d /run/systemd/system ] || init=1 printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$(hostname)" "$ID" "${VERSION_CODENAME:-unknown}" "$ts" "$wz" "$mgr" "$tun" "$init" done PROBE ) || { say "Uebersprungen: LXC $id (Pruefung fehlgeschlagen)."; continue; } IFS='|' read -r name os codename ts wz mgr tun init rest <<<"$meta" [[ $mgr == 0 ]] || MANAGERS+=("$id") if [[ $rest != done || $init != 1 || ! $os =~ ^(debian|ubuntu)$ || ! $codename =~ ^[a-z]+$ ]]; then say "Uebersprungen: LXC $id (unterstuetzt: Debian/Ubuntu mit systemd und Versions-Codename)."; continue fi if [[ $ts == 1 && $wz == 1 ]]; then say "Ausgeblendet: LXC $id $name (beide Komponenten vorhanden; Verbindung nicht geprueft)."; continue fi [[ $name =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ ]] || { say "Uebersprungen: LXC $id (Hostname ungueltig)."; continue; } IDS+=("$id"); NAMES[$id]=$name; TS[$id]=$ts; WZ[$id]=$wz; TUN[$id]=$tun done <<<"$rows" say ''; say 'Auswaehlbare LXCs (nur fehlende Komponenten werden installiert):' printf '%-8s %-24s %-12s %-12s %s\n' 'CTID' 'Name' 'Tailscale' 'Wazuh' 'TUN' for id in "${IDS[@]}"; do printf '%-8s %-24s %-12s %-12s %s\n' "$id" "${NAMES[$id]}" "$(installed_label "${TS[$id]}")" "$(installed_label "${WZ[$id]}")" "$(installed_label "${TUN[$id]}")" done } choose_lxcs() { local menu_height id rc local -a items=() if [[ -z $UI_TOOL ]]; then ask 'CTIDs auswaehlen (Leerzeichen, oder all; leer = abbrechen):' SELECTION=$REPLY [[ -n $SELECTION ]] return fi menu_height=$((UI_HEIGHT-9)) if [[ $menu_height -gt ${#IDS[@]} ]]; then menu_height=${#IDS[@]}; fi for id in "${IDS[@]}"; do items+=("$id" "${NAMES[$id]} | Tailscale: $(installed_label "${TS[$id]}") | Wazuh: $(installed_label "${WZ[$id]}") | TUN: $(installed_label "${TUN[$id]}")" OFF) done # UI reads/writes the controlling terminal. Only CTIDs go to captured fd 4. # Never eval/source the dialog output; the normal selection validation follows. if ui_capture --title 'LXC Enrollment - Container auswaehlen' \ --ok-button 'Weiter' --cancel-button 'Abbrechen' \ --separate-output --checklist \ 'Pfeile: navigieren | Leertaste: markieren | Tab: Schaltflaeche | Enter: bestaetigen' \ "$UI_HEIGHT" "$UI_WIDTH" "$menu_height" "${items[@]}"; then SELECTION=${REPLY//$'\n'/ } [[ -n $SELECTION ]] return else rc=$? case "$rc" in 1|255) return 1;; *) fail "LXC-Checkliste fehlgeschlagen (Status $rc).";; esac fi } ensure_tun() { local id=$1 cfg slot=-1 n cfg=$(pct config "$id") || return 1 if ! grep -Eq '^dev[0-9]+:.*(/dev/net/tun)(,|$)' <<<"$cfg"; then for n in {0..255}; do if ! grep -q "^dev$n:" <<<"$cfg"; then slot=$n; break; fi done [[ $slot != -1 ]] || { say "LXC $id: Kein freier Device-Slot."; return 1; } printf '%s\n' "$cfg" >"$TMP/lxc-$id.conf.before" || return 1 # Backups survive this run; no secrets are stored in these files. install -d -m 700 /var/backups/lxc-enroll || return 1 install -m 600 "$TMP/lxc-$id.conf.before" "/var/backups/lxc-enroll/$id-$(date +%Y%m%d-%H%M%S).conf" || return 1 pct set "$id" "--dev$slot" /dev/net/tun || return 1 fi # Never forcibly stop a workload. If clean shutdown fails, stop this CT's work. pct shutdown "$id" --timeout 90 || return 1 pct start "$id" || return 1 for n in {1..30}; do if pct exec "$id" -- test -c /dev/net/tun 2>/dev/null; then return 0; fi sleep 2 done say "LXC $id: TUN nach Neustart nicht verfuegbar."; return 1 } write_worker() { cat >"$TMP/worker.sh" <<'WORKER' #!/usr/bin/env bash set -Eeuo pipefail set +x umask 077 DIR=$1; MANAGER=$2; AGENT_VERSION=$3; LOGIN=$4; CT_HOSTNAME=$5; WANT_TS=$6; WANT_WZ=$7 SSH_ENABLED=$8; SSH_USER=$9; SSH_SUDO=${10}; SSH_NOPASSWD=${11} TS_ACCEPT_DNS=${12} RESUME_TS=${13} STEP='Vorbereitung' trap 'rc=$?; printf "FEHLER: %s (Status %s, Worker-Zeile %s).\n" "$STEP" "$rc" "$LINENO" >&2; exit "$rc"' ERR case "$TS_ACCEPT_DNS" in 1) DNS_FLAG=true;; 0) DNS_FLAG=false;; *) exit 1;; esac trap 'rm -f -- "$DIR/headscale.key" "$DIR/enrollment.pass" "$DIR/sudo.pass"' EXIT export DEBIAN_FRONTEND=noninteractive present() { [[ $(dpkg-query -W -f='${Status}' "$1" 2>/dev/null || true) == 'install ok installed' ]]; } download() { curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 60 --retry 2 "$1" -o "$2"; } has_repo() { grep -Rqs -- "$1" /etc/apt/sources.list.d 2>/dev/null || grep -qs -- "$1" /etc/apt/sources.list 2>/dev/null; } # Recheck before installing; never overwrite an existing component or enrollment. if present tailscale || command -v tailscale >/dev/null; then WANT_TS=0; fi if present wazuh-agent || present wazuh-manager || [[ -x /var/ossec/bin/wazuh-agentd ]]; then WANT_WZ=0; fi if [[ $WANT_WZ == 1 && -e /var/ossec/etc/ossec.conf ]]; then echo 'Wazuh-Konfiguration bereits vorhanden: bitte unvollstaendige Installation zuerst reparieren.' >&2; exit 1 fi JOIN_TS=$WANT_TS if [[ $WANT_TS == 0 && $RESUME_TS == 1 ]]; then systemctl enable --now tailscaled status_json=$(tailscale status --json) || true backend_state=$(printf '%s\n' "$status_json" | sed -n 's/.*"BackendState"[[:space:]]*:[[:space:]]*"\([A-Za-z]*\)".*/\1/p') case "$backend_state" in NeedsLogin|NoState) JOIN_TS=1;; Running) echo 'Tailscale bereits angemeldet; Registrierung bleibt erhalten.';; *) echo "Tailscale-Status ${backend_state:-unbekannt}: bitte zuerst pruefen; keine Neuregistrierung." >&2; exit 1;; esac fi [[ $JOIN_TS == 1 || $WANT_WZ == 1 || $SSH_ENABLED == 1 ]] || { echo 'Komponenten inzwischen vorhanden; nichts geaendert.'; exit 0; } STEP='APT-Grundpakete' apt-get -o DPkg::Lock::Timeout=120 update apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends ca-certificates curl gnupg install -d -m 755 /usr/share/keyrings /etc/apt/sources.list.d if [[ $WANT_TS == 1 ]]; then STEP='Tailscale-Installation' # Isolate distribution variables: os-release also defines NAME and VERSION. os_details=$( . /etc/os-release; printf '%s|%s' "$ID" "$VERSION_CODENAME") IFS='|' read -r DISTRO_ID DISTRO_CODENAME <<<"$os_details" if ! has_repo 'pkgs.tailscale.com'; then [[ ! -e /etc/apt/sources.list.d/tailscale.list ]] || { echo 'Vorhandene tailscale.list bitte zuerst pruefen.' >&2; exit 1; } download "https://pkgs.tailscale.com/stable/$DISTRO_ID/$DISTRO_CODENAME.noarmor.gpg" "$DIR/tailscale.gpg" download "https://pkgs.tailscale.com/stable/$DISTRO_ID/$DISTRO_CODENAME.tailscale-keyring.list" "$DIR/tailscale.list" install -m 644 "$DIR/tailscale.gpg" /usr/share/keyrings/tailscale-archive-keyring.gpg install -m 644 "$DIR/tailscale.list" /etc/apt/sources.list.d/tailscale.list fi apt-get -o DPkg::Lock::Timeout=120 update apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends tailscale systemctl enable --now tailscaled fi if [[ $JOIN_TS == 1 ]]; then STEP='Headscale-Anmeldung' [[ -c /dev/net/tun && -s $DIR/headscale.key ]] || { echo 'TUN oder Headscale-Schluessel fehlt.' >&2; exit 1; } # File reference keeps the key out of command arguments/history. timeout 90 tailscale up --login-server="$LOGIN" --auth-key="file:$DIR/headscale.key" --hostname="$CT_HOSTNAME" --accept-dns="$DNS_FLAG" --timeout=60s rm -f -- "$DIR/headscale.key" tailscale ip -4 echo 'Tailscale angemeldet.' fi # Explicitly accepted DNS settings may also enable MagicDNS on an existing client. # Do not disable or reauthenticate an existing client when this option is declined. STEP='Headscale-DNS' if [[ $TS_ACCEPT_DNS == 1 && $WANT_TS == 0 ]]; then tailscale set --accept-dns=true; fi if [[ $WANT_WZ == 1 ]]; then STEP='Wazuh-Installation und Verbindung' # A Headscale manager IP only becomes reachable after the new client joins. for port in 1514 1515; do timeout 5 bash -c 'exec 7<>/dev/tcp/$1/$2' -- "$MANAGER" "$port" || { echo "Wazuh-Manager auf Port $port nicht erreichbar; Agent-Installation abgebrochen." >&2; exit 1; } done if ! has_repo 'packages.wazuh.com'; then [[ ! -e /etc/apt/sources.list.d/lxc-enroll-wazuh.list ]] || exit 1 download https://packages.wazuh.com/key/GPG-KEY-WAZUH "$DIR/wazuh.asc" gpg --batch --yes --dearmor -o "$DIR/wazuh.gpg" "$DIR/wazuh.asc" install -m 644 "$DIR/wazuh.gpg" /usr/share/keyrings/lxc-enroll-wazuh.gpg printf '%s\n' 'deb [signed-by=/usr/share/keyrings/lxc-enroll-wazuh.gpg] https://packages.wazuh.com/4.x/apt/ stable main' >/etc/apt/sources.list.d/lxc-enroll-wazuh.list chmod 644 /etc/apt/sources.list.d/lxc-enroll-wazuh.list fi apt-get -o DPkg::Lock::Timeout=120 update apt-cache madison wazuh-agent | awk '{print $3}' | grep -Fx "$AGENT_VERSION" >/dev/null || { echo "Wazuh-Version $AGENT_VERSION nicht verfuegbar; kein neuerer Agent wird installiert." >&2; exit 1; } export WAZUH_MANAGER="$MANAGER" WAZUH_REGISTRATION_SERVER="$MANAGER" WAZUH_AGENT_NAME="$CT_HOSTNAME" if [[ -s $DIR/enrollment.pass ]]; then export WAZUH_REGISTRATION_PASSWORD; WAZUH_REGISTRATION_PASSWORD=$(cat "$DIR/enrollment.pass"); fi apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends "wazuh-agent=$AGENT_VERSION" unset WAZUH_REGISTRATION_PASSWORD rm -f -- "$DIR/enrollment.pass" apt-mark hold wazuh-agent systemctl daemon-reload systemctl enable --now wazuh-agent connected=0 for attempt in {1..30}; do if grep -q "^status='connected'" /var/ossec/var/run/wazuh-agentd.state 2>/dev/null; then connected=1; break fi sleep 2 done if [[ $connected == 0 ]]; then echo 'Wazuh installiert, Verbindung nicht bestaetigt. Agent-Konfiguration/Logs pruefen; keine automatische Neuregistrierung.' >&2 exit 1 fi echo 'Wazuh installiert und mit dem Manager verbunden.' fi if [[ $SSH_ENABLED == 1 ]]; then STEP='Tailscale-SSH und Nutzer' if [[ $SSH_SUDO == 1 ]]; then apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends sudo; fi new_user=0 if id "$SSH_USER" >/dev/null 2>&1; then [[ $(id -u "$SSH_USER") != 0 ]] || { echo 'SSH-Ziel darf kein root-Konto sein.' >&2; exit 1; } else useradd -m -U -s /bin/bash "$SSH_USER" new_user=1 fi if [[ $SSH_SUDO == 1 ]]; then usermod -aG sudo "$SSH_USER" if [[ $SSH_NOPASSWD == 1 ]]; then sudo_rule="/etc/sudoers.d/lxc-enroll-$SSH_USER" printf '%s ALL=(ALL:ALL) NOPASSWD: ALL\n' "$SSH_USER" >"$DIR/sudoers" visudo -cf "$DIR/sudoers" if [[ -e $sudo_rule ]] && ! cmp -s "$DIR/sudoers" "$sudo_rule"; then echo 'Abweichende vorhandene Sudo-Regel: bitte manuell pruefen.' >&2; exit 1 fi install -m 440 "$DIR/sudoers" "$sudo_rule" elif [[ $new_user == 1 ]]; then [[ -s $DIR/sudo.pass ]] || { echo 'Passwort fuer neuen Sudo-Nutzer fehlt.' >&2; exit 1; } { printf '%s:' "$SSH_USER"; cat "$DIR/sudo.pass"; printf '\n'; } | chpasswd echo 'Passwort fuer neuen Nutzer gesetzt; bestehende Nutzerpasswoerter bleiben unveraendert.' fi fi rm -f -- "$DIR/sudo.pass" tailscale set --ssh=true echo "Tailscale SSH fuer $SSH_USER aktiviert. Headscale-Netzwerk- UND SSH-Policy muessen diesen Zugriff erlauben." echo 'Kein OpenSSH-Server installiert. Vorhandene Nutzer-/Sudo-Rechte werden nicht entzogen.' fi WORKER } deploy_step() { local id=$1 step=$2 rc shift 2 if "$@"; then return 0 else rc=$? say "LXC $id: $step fehlgeschlagen (Status $rc)." >&2 return 1 fi } deploy_one() { local id=$1 dir ts wz [[ $(pct status "$id") == 'status: running' ]] || { say "LXC $id: Container nicht mehr laufend." >&2; return 1; } if pct config "$id" | grep -q '^lock:'; then say "LXC $id inzwischen gesperrt."; return 1; fi ts=$((1-${TS[$id]})); wz=$((1-${WZ[$id]})) if [[ ( $ts == 1 || $RESUME_TS == 1 ) && ${TUN[$id]} == 0 ]]; then deploy_step "$id" 'TUN-Einrichtung/Neustart' ensure_tun "$id" || return 1; fi dir=$(deploy_step "$id" 'Temporäres Verzeichnis' pct exec "$id" -- mktemp -d /run/lxc-enroll.XXXXXXXX) || return 1 [[ $dir =~ ^/run/lxc-enroll\.[A-Za-z0-9]+$ ]] || { say "LXC $id: Unerwarteter temporaerer Pfad." >&2; return 1; } REMOTE_DIRS[$id]=$dir deploy_step "$id" 'Worker-Datei kopieren' pct push "$id" "$TMP/worker.sh" "$dir/worker.sh" --user 0 --group 0 --perms 600 || return 1 if [[ $ts == 1 || $RESUME_TS == 1 ]]; then deploy_step "$id" 'Headscale-Schluessel kopieren' pct push "$id" "$TMP/headscale.key" "$dir/headscale.key" --user 0 --group 0 --perms 600 || return 1; fi if [[ $wz == 1 && -s $TMP/enrollment.pass ]]; then deploy_step "$id" 'Enrollment-Passwort kopieren' pct push "$id" "$TMP/enrollment.pass" "$dir/enrollment.pass" --user 0 --group 0 --perms 600 || return 1; fi if [[ $SSH_ENABLED == 1 ]]; then if [[ -s $TMP/sudo.pass ]]; then deploy_step "$id" 'Sudo-Passwort kopieren' pct push "$id" "$TMP/sudo.pass" "$dir/sudo.pass" --user 0 --group 0 --perms 600 || return 1; fi fi # Credentials are files, never pct arguments. bash receives only non-secret settings. deploy_step "$id" 'Einrichtung im Container' pct exec "$id" -- bash "$dir/worker.sh" "$dir" "$MANAGER" "$VERSION" "$LOGIN_SERVER" "${NAMES[$id]}" "$ts" "$wz" "$SSH_ENABLED" "$SSH_USER" "$SSH_SUDO" "$SSH_NOPASSWD" "$TS_ACCEPT_DNS" "$RESUME_TS" || return 1 if [[ $ts == 1 || $RESUME_TS == 1 ]]; then pct exec "$id" -- tailscale status --json | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("BackendState")=="Running" and d.get("Self",{}).get("Online") is True else 1)' || { say "LXC $id: Headscale-Verbindung nicht als online bestaetigt."; return 1; } fi deploy_step "$id" 'Temporaere Dateien entfernen' pct exec "$id" -- rm -rf -- "$dir" || return 1 REMOTE_DIRS[$id]='' } main() { local id input wanted found need_ts=0 need_wz=0 restart_ids='' ok='' failed='' auto_version password plan='' scan [[ $LIST == 0 ]] || return 0 [[ ${#IDS[@]} -gt 0 ]] || { say 'Keine passenden LXCs gefunden.'; return 0; } exec 3<>/dev/tty || fail 'Interaktives Terminal fehlt. --list kann ohne Terminal laufen.' init_ui load_settings yes_no "Tailscale SSH fuer alle ausgewaehlten LXCs aktivieren? $(choice_hint "$SSH_ENABLED")" "$SSH_ENABLED" SSH_ENABLED=$REPLY if [[ $SSH_ENABLED == 1 ]]; then ask "SSH-Nutzername${SSH_USER:+ [$SSH_USER]}:" "$SSH_USER" SSH_USER=${REPLY:-$SSH_USER} [[ $SSH_USER =~ ^[a-z_][a-z0-9_-]{0,30}$ && $SSH_USER != root ]] || fail 'Bitte einen normalen Linux-Nutzernamen verwenden.' yes_no "Nutzer zu sudo hinzufuegen? $(choice_hint "$SSH_SUDO")" "$SSH_SUDO"; SSH_SUDO=$REPLY if [[ $SSH_SUDO == 1 ]]; then yes_no "Sudo OHNE Passwort erlauben? $(choice_hint "$SSH_NOPASSWD")" "$SSH_NOPASSWD"; SSH_NOPASSWD=$REPLY fi fi yes_no 'Einstellungen ohne Geheimnisse lokal in .vars speichern? [J/n]' 1; SAVE_SETTINGS=$REPLY if [[ -z $SELECTION ]]; then choose_lxcs || { say 'Abgebrochen; keine Container ausgewaehlt.'; return 0; } fi [[ -n $SELECTION ]] || return 0 if [[ $SELECTION == all ]]; then SELECTED=("${IDS[@]}"); else SELECTION=${SELECTION//,/ } for wanted in $SELECTION; do [[ $wanted =~ ^[1-9][0-9]*$ ]] || fail 'Bitte nur CTIDs eingeben.' found=0 for id in "${IDS[@]}"; do [[ $id != "$wanted" ]] || found=1; done [[ $found == 1 ]] || fail "LXC $wanted ist nicht auswaehlbar." for id in "${SELECTED[@]}"; do [[ $id != "$wanted" ]] || fail "LXC $wanted doppelt ausgewaehlt."; done SELECTED+=("$wanted") done fi say ''; say 'Geplante Aenderungen:' for id in "${SELECTED[@]}"; do say "LXC $id ${NAMES[$id]}: Tailscale $(installed_label "${TS[$id]}"), Wazuh $(installed_label "${WZ[$id]}")." plan+="LXC $id ${NAMES[$id]}: Tailscale $(installed_label "${TS[$id]}"), Wazuh $(installed_label "${WZ[$id]}")"$'\n' if [[ ${TS[$id]} == 0 || $RESUME_TS == 1 ]]; then need_ts=1 if [[ ${TUN[$id]} == 0 ]]; then restart_ids+=" $id"; fi fi [[ ${WZ[$id]} != 0 ]] || need_wz=1 done if [[ $RESUME_TS == 1 ]]; then say 'Wiederaufnahme: vorhandenes Tailscale nur bei NeedsLogin/NoState anmelden; angemeldete Clients behalten ihre Registrierung.'; fi if [[ -n $restart_ids ]]; then say "TUN-Zugriff ergaenzen und sauber neu starten:$restart_ids"; fi if [[ $SSH_ENABLED == 1 ]]; then say "Tailscale SSH fuer die Auswahl: Nutzer $SSH_USER, Sudo=$SSH_SUDO, Sudo-ohne-Passwort=$SSH_NOPASSWD." say 'Headscale muss Zugriff auf Port 22 und den gewaehlten Linux-Nutzer in seiner SSH-Policy erlauben.' say 'Bestehende SSH-Sitzungen ueber Tailscale koennen beim Aktivieren unterbrochen werden.' fi [[ $DRY == 0 ]] || { say 'Vorschau abgeschlossen. Keine Aenderungen vorgenommen.'; return 0; } command -v flock >/dev/null || fail 'flock fehlt auf dem Host.' exec 8>/run/lxc-enroll.lock flock -n 8 || fail 'Ein anderer Enrollment-Lauf ist bereits aktiv.' if [[ $need_wz == 1 ]]; then if [[ -z $MANAGER ]]; then ask 'Erreichbare Wazuh-Manager-Adresse (IP oder DNS-Name):'; MANAGER=$REPLY; fi [[ $MANAGER =~ ^[A-Za-z0-9][A-Za-z0-9.:-]*$ ]] || fail 'Manager-Adresse ungueltig.' if [[ ${#MANAGERS[@]} == 1 ]]; then auto_version=$(pct exec "${MANAGERS[0]}" -- dpkg-query -W '-f=${Version}' wazuh-manager) || fail 'Manager-Version nicht lesbar.' if [[ $VERSION_EXPLICIT == 1 && $VERSION != "$auto_version" ]]; then fail 'Agent-Version muss der lokalen Manager-Version entsprechen.'; fi VERSION=$auto_version elif [[ -z $VERSION ]]; then ask 'Exakte Manager-Paketversion (z.B. 4.14.8-1):'; VERSION=$REPLY; fi [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+-[0-9]+$ ]] || fail 'Paketversion ungueltig.' say "Neue Wazuh-Agents: $VERSION (anschliessend auf hold; gemeinsam mit Manager aktualisieren)." fi if [[ $need_ts == 1 ]]; then if [[ -z $LOGIN_SERVER ]]; then ask 'Headscale-Login-Server (vollstaendige HTTPS-URL):'; LOGIN_SERVER=$REPLY; fi [[ $LOGIN_SERVER =~ ^https://[A-Za-z0-9][A-Za-z0-9.:-]*/?$ ]] || fail 'Bitte eine HTTPS-Server-URL ohne Zugangsdaten/Query eingeben.' say 'Neue Tailscale-Clients verwenden Headscale.' fi if [[ $need_wz == 1 && ! $MANAGER =~ ^[0-9.:]+$ && $DNS_SAVED == 0 ]]; then TS_ACCEPT_DNS=1; fi if [[ $TS_ACCEPT_DNS == 1 ]]; then input='[J/n]'; else input='[j/N]'; fi yes_no "Headscale-DNS/MagicDNS fuer die Auswahl uebernehmen? $input" "$TS_ACCEPT_DNS" TS_ACCEPT_DNS=$REPLY if [[ $TS_ACCEPT_DNS == 1 ]]; then say 'Headscale-DNS wird fuer ausgewaehlte Clients aktiviert. Lokale Namensaufloesung muss zu den Headscale-DNS-Einstellungen passen.' else say 'Neue Clients behalten lokales DNS bei. Der Manager-Name muss ueber diesen Resolver aufloesbar sein.' fi if [[ -n $UI_TOOL ]]; then local -a scroll_option=() if [[ $UI_TOOL == whiptail ]]; then scroll_option=(--scrolltext); else scroll_option=(--scrollbar); fi plan+=$'\n'"Wazuh-Manager: ${MANAGER:-nicht benoetigt}; neue Agent-Version: ${VERSION:-keine}"$'\n' plan+="Headscale: ${LOGIN_SERVER:-nicht benoetigt}; DNS uebernehmen: $(yesno_label "$TS_ACCEPT_DNS")"$'\n' plan+="Tailscale SSH: $(yesno_label "$SSH_ENABLED"); Nutzer: ${SSH_USER:-keiner}; Sudo: $(yesno_label "$SSH_SUDO"); Sudo ohne Passwort: $(yesno_label "$SSH_NOPASSWD")"$'\n' if [[ $SSH_ENABLED == 1 ]]; then plan+='Tailscale-SSH-Sitzungen koennen unterbrochen werden. Zugriff braucht eine passende Headscale-Policy.'$'\n'; fi plan+="TUN ergaenzen und neu starten:${restart_ids:- keine}"$'\n' if [[ $RESUME_TS == 1 ]]; then plan+='Wiederaufnahme: noch nicht angemeldetes Tailscale anmelden.'$'\n'; fi ui_capture --title 'LXC Enrollment - Geplante Aenderungen' --ok-button 'Pruefen und weiter' \ "${scroll_option[@]}" --msgbox "$plan" "$UI_HEIGHT" "$UI_WIDTH" || fail 'Pruefung abgebrochen.' fi ask 'Nur diese fehlenden Komponenten und gewaehlten Einstellungen einrichten? Bitte INSTALL eingeben:' [[ $REPLY == INSTALL ]] || { say 'Abgebrochen.'; return 0; } if [[ -n $restart_ids ]]; then ask "Diese Container werden kurz unterbrochen:$restart_ids. Bitte NEUSTART eingeben:" [[ $REPLY == NEUSTART ]] || { say 'Abgebrochen; nichts installiert.'; return 0; } fi TMP=$(mktemp -d /run/lxc-enroll.XXXXXXXX) if [[ $SSH_ENABLED == 1 ]]; then if [[ $SSH_SUDO == 1 && $SSH_NOPASSWD == 0 ]]; then secret 'Passwort fuer NEU angelegte Sudo-Nutzer (bestehende Passwoerter bleiben unveraendert):'; password=$REPLY [[ -n $password && $password != *:* && $password != *$'\r'* ]] || fail 'Passwort leer oder enthaelt ungueltige Zeichen.' secret 'Passwort wiederholen:'; [[ $REPLY == "$password" ]] || fail 'Passwoerter stimmen nicht ueberein.' printf '%s' "$password" >"$TMP/sudo.pass"; password=''; REPLY='' fi fi save_settings if [[ $need_ts == 1 ]]; then secret 'Wiederverwendbaren, kurz gueltigen Headscale-Preauth-Key eingeben (wird nicht angezeigt):' [[ -n $REPLY && $REPLY != *$'\r'* ]] || fail 'Schluessel fehlt/ungueltig.' printf '%s' "$REPLY" >"$TMP/headscale.key"; REPLY='' fi if [[ $need_wz == 1 ]]; then secret 'Optionales Wazuh-Enrollment-Passwort (leer, falls keines verlangt wird):' printf '%s' "$REPLY" >"$TMP/enrollment.pass"; REPLY='' fi write_worker for id in "${SELECTED[@]}"; do say ''; say "Bearbeite LXC $id ${NAMES[$id]} ..." # All deployment operations explicitly propagate errors. Parent owns cleanup. if deploy_one "$id"; then ok+=" $id"; else failed+=" $id"; fi done say ''; say "Erfolgreich abgeschlossen:${ok:- keine}" say "Nicht abgeschlossen:${failed:- keine}" say 'Vorhandene Wazuh-Registrierungen bleiben erhalten. DNS/SSH wurden nur gemaess Auswahl geaendert.' say 'Enrollment richtet noch keine Discord-Regeln oder vollstaendige Schwachstellenabdeckung ein.' [[ -z $failed ]] } main