2026-10-04 11:40:25 +02:00
#!/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' " $* " ; }
2026-10-04 12:20:27 +02:00
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
}
2026-10-04 11:40:25 +02:00
secret() {
2026-10-04 12:20:27 +02:00
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
2026-10-04 11:40:25 +02:00
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 ; }
2026-10-04 12:00:24 +02:00
choice_hint() { if [[ $1 == 1 ]] ; then printf '[J/n]' ; else printf '[j/N]' ; fi ; }
2026-10-04 12:20:27 +02:00
yesno_label() { if [[ $1 == 1 ]] ; then printf 'Ja' ; else printf 'Nein' ; fi ; }
2026-10-04 11:40:25 +02:00
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
2026-10-04 12:00:24 +02:00
TS_ACCEPT_DNS = 0; DNS_SAVED = 0; RESUME_TS = 0
2026-10-04 11:40:25 +02:00
VARS_FILE = /etc/lxc-enroll/.vars
while [[ $# -gt 0 ]] ; do
case " $1 " in
--list) LIST = 1; shift ;;
--dry-run) DRY = 1; shift ;;
2026-10-04 12:00:24 +02:00
--resume-tailscale) RESUME_TS = 1; shift ;;
2026-10-04 11:40:25 +02:00
--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
2026-10-04 12:00:24 +02:00
--select 'CTID CTID' --list --dry-run --resume-tailscale
--resume-tailscale: Bereits installiertes Tailscale nur bei NeedsLogin/NoState
anmelden. Bestehende Registrierungen werden nicht ersetzt.
2026-10-04 11:40:25 +02:00
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.
2026-10-04 12:20:27 +02:00
Dialogoberflaeche fuer Auswahl, Eingaben und Geheimnisse (whiptail/dialog).
LXC-Checkliste: Leertaste markiert. Ohne Dialogwerkzeug: Texteingabe.
2026-10-04 11:40:25 +02:00
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() {
2026-10-04 12:20:27 +02:00
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
2026-10-04 11:40:25 +02:00
ask " $prompt "
2026-10-04 12:00:24 +02:00
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
2026-10-04 11:40:25 +02:00
}
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))
2026-10-04 11:50:50 +02:00
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
2026-10-04 11:40:25 +02:00
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 ;;
2026-10-04 11:50:50 +02:00
ts_accept_dns) [[ $value = ~ ^[ 01] $ ]] || fail 'Ungueltige DNS-Einstellung.' ; TS_ACCEPT_DNS = $value ; DNS_SAVED = 1;;
2026-10-04 11:40:25 +02:00
esac
done <<< " $fields "
}
save_settings() {
[[ $SAVE_SETTINGS == 1 ]] || return 0
install -d -m 700 /etc/lxc-enroll
2026-10-04 11:50:50 +02:00
python3 - " $VARS_FILE " " $MANAGER " " $VERSION " " $LOGIN_SERVER " " $SSH_ENABLED " " $SSH_USER " " $SSH_SUDO " " $SSH_NOPASSWD " " $TS_ACCEPT_DNS " <<'PY'
2026-10-04 11:40:25 +02:00
import json, os, sys, tempfile
p=sys.argv[1]
2026-10-04 11:50:50 +02:00
keys=('manager','agent_version','login_server','ssh_enabled','ssh_user','ssh_sudo','ssh_nopasswd','ts_accept_dns')
2026-10-04 11:40:25 +02:00
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
}
2026-10-04 12:20:27 +02:00
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
}
2026-10-04 11:40:25 +02:00
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
2026-10-04 12:00:24 +02:00
DIR=$1; MANAGER=$2; AGENT_VERSION=$3; LOGIN=$4; CT_HOSTNAME=$5; WANT_TS=$6; WANT_WZ=$7
2026-10-04 11:40:25 +02:00
SSH_ENABLED=$8; SSH_USER=$9; SSH_SUDO=${10}; SSH_NOPASSWD=${11}
2026-10-04 11:50:50 +02:00
TS_ACCEPT_DNS=${12}
2026-10-04 12:00:24 +02:00
RESUME_TS=${13}
2026-10-04 13:07:13 +02:00
STEP='Vorbereitung'
trap 'rc=$?; printf "FEHLER: %s (Status %s, Worker-Zeile %s).\n" "$STEP" "$rc" "$LINENO" >&2; exit "$rc"' ERR
2026-10-04 11:50:50 +02:00
case "$TS_ACCEPT_DNS" in 1) DNS_FLAG=true;; 0) DNS_FLAG=false;; *) exit 1;; esac
2026-10-04 11:40:25 +02:00
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
2026-10-04 12:00:24 +02:00
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; }
2026-10-04 11:40:25 +02:00
2026-10-04 13:07:13 +02:00
STEP='APT-Grundpakete'
2026-10-04 11:40:25 +02:00
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
2026-10-04 13:07:13 +02:00
STEP='Tailscale-Installation'
2026-10-04 12:00:24 +02:00
# 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"
2026-10-04 11:40:25 +02:00
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; }
2026-10-04 12:00:24 +02:00
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"
2026-10-04 11:40:25 +02:00
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
2026-10-04 12:00:24 +02:00
fi
if [[ $JOIN_TS == 1 ]]; then
2026-10-04 13:07:13 +02:00
STEP='Headscale-Anmeldung'
2026-10-04 12:00:24 +02:00
[[ -c /dev/net/tun && -s $DIR/headscale.key ]] || { echo 'TUN oder Headscale-Schluessel fehlt.' >&2; exit 1; }
2026-10-04 11:50:50 +02:00
# File reference keeps the key out of command arguments/history.
2026-10-04 12:00:24 +02:00
timeout 90 tailscale up --login-server="$LOGIN" --auth-key="file:$DIR/headscale.key" --hostname="$CT_HOSTNAME" --accept-dns="$DNS_FLAG" --timeout=60s
2026-10-04 11:40:25 +02:00
rm -f -- "$DIR/headscale.key"
tailscale ip -4
2026-10-04 12:00:24 +02:00
echo 'Tailscale angemeldet.'
2026-10-04 11:40:25 +02:00
fi
2026-10-04 11:50:50 +02:00
# 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.
2026-10-04 13:07:13 +02:00
STEP='Headscale-DNS'
2026-10-04 11:50:50 +02:00
if [[ $TS_ACCEPT_DNS == 1 && $WANT_TS == 0 ]]; then tailscale set --accept-dns=true; fi
2026-10-04 11:40:25 +02:00
if [[ $WANT_WZ == 1 ]]; then
2026-10-04 13:07:13 +02:00
STEP='Wazuh-Installation und Verbindung'
2026-10-04 11:41:44 +02:00
# 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
2026-10-04 11:40:25 +02:00
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;
}
2026-10-04 12:00:24 +02:00
export WAZUH_MANAGER="$MANAGER" WAZUH_REGISTRATION_SERVER="$MANAGER" WAZUH_AGENT_NAME="$CT_HOSTNAME"
2026-10-04 11:40:25 +02:00
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
2026-10-04 13:07:13 +02:00
STEP='Tailscale-SSH und Nutzer'
2026-10-04 11:40:25 +02:00
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
}
2026-10-04 13:07:13 +02:00
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
}
2026-10-04 11:40:25 +02:00
deploy_one() {
local id = $1 dir ts wz
2026-10-04 13:07:13 +02:00
[[ $( pct status " $id " ) == 'status: running' ]] || { say "LXC $id : Container nicht mehr laufend." >& 2; return 1; }
2026-10-04 11:40:25 +02:00
if pct config " $id " | grep -q '^lock:' ; then say "LXC $id inzwischen gesperrt." ; return 1; fi
ts = $(( 1 - ${ TS [ $id ] } )) ; wz = $(( 1 - ${ WZ [ $id ] } ))
2026-10-04 13:07:13 +02:00
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; }
2026-10-04 11:40:25 +02:00
REMOTE_DIRS[ $id ]= $dir
2026-10-04 13:07:13 +02:00
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
2026-10-04 11:40:25 +02:00
if [[ $SSH_ENABLED == 1 ]] ; then
2026-10-04 13:07:13 +02:00
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
2026-10-04 11:40:25 +02:00
fi
# Credentials are files, never pct arguments. bash receives only non-secret settings.
2026-10-04 13:07:13 +02:00
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
2026-10-04 12:00:24 +02:00
if [[ $ts == 1 || $RESUME_TS == 1 ]] ; then
2026-10-04 11:40:25 +02:00
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
2026-10-04 13:07:13 +02:00
deploy_step " $id " 'Temporaere Dateien entfernen' pct exec " $id " -- rm -rf -- " $dir " || return 1
2026-10-04 11:40:25 +02:00
REMOTE_DIRS[ $id ]= ''
}
main() {
2026-10-04 12:20:27 +02:00
local id input wanted found need_ts = 0 need_wz = 0 restart_ids = '' ok = '' failed = '' auto_version password plan = ''
2026-10-04 11:40:25 +02:00
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.'
2026-10-04 12:20:27 +02:00
init_ui
2026-10-04 11:40:25 +02:00
load_settings
2026-10-04 12:00:24 +02:00
yes_no "Tailscale SSH fuer alle ausgewaehlten LXCs aktivieren? $( choice_hint " $SSH_ENABLED " ) " " $SSH_ENABLED "
2026-10-04 11:40:25 +02:00
SSH_ENABLED = $REPLY
if [[ $SSH_ENABLED == 1 ]] ; then
2026-10-04 12:20:27 +02:00
ask "SSH-Nutzername ${ SSH_USER :+ [ $SSH_USER ] } :" " $SSH_USER "
2026-10-04 11:40:25 +02:00
SSH_USER = ${ REPLY :- $SSH_USER }
[[ $SSH_USER = ~ ^[ a-z_][ a-z0-9_-]{ 0,30} $ && $SSH_USER != root ]] || fail 'Bitte einen normalen Linux-Nutzernamen verwenden.'
2026-10-04 12:00:24 +02:00
yes_no "Nutzer zu sudo hinzufuegen? $( choice_hint " $SSH_SUDO " ) " " $SSH_SUDO " ; SSH_SUDO = $REPLY
2026-10-04 11:40:25 +02:00
if [[ $SSH_SUDO == 1 ]] ; then
2026-10-04 12:00:24 +02:00
yes_no "Sudo OHNE Passwort erlauben? $( choice_hint " $SSH_NOPASSWD " ) " " $SSH_NOPASSWD " ; SSH_NOPASSWD = $REPLY
2026-10-04 11:40:25 +02:00
fi
fi
yes_no 'Einstellungen ohne Geheimnisse lokal in .vars speichern? [J/n]' 1; SAVE_SETTINGS = $REPLY
2026-10-04 12:20:27 +02:00
if [[ -z $SELECTION ]] ; then
choose_lxcs || { say 'Abgebrochen; keine Container ausgewaehlt.' ; return 0; }
fi
2026-10-04 11:40:25 +02:00
[[ -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 ] } " ) ."
2026-10-04 12:20:27 +02:00
plan += "LXC $id ${ NAMES [ $id ] } : Tailscale $( installed_label " ${ TS [ $id ] } " ) , Wazuh $( installed_label " ${ WZ [ $id ] } " ) " $'\n'
2026-10-04 12:00:24 +02:00
if [[ ${ TS [ $id ] } == 0 || $RESUME_TS == 1 ]] ; then
2026-10-04 11:40:25 +02:00
need_ts = 1
if [[ ${ TUN [ $id ] } == 0 ]] ; then restart_ids += " $id " ; fi
fi
[[ ${ WZ [ $id ] } != 0 ]] || need_wz = 1
done
2026-10-04 12:00:24 +02:00
if [[ $RESUME_TS == 1 ]] ; then say 'Wiederaufnahme: vorhandenes Tailscale nur bei NeedsLogin/NoState anmelden; angemeldete Clients behalten ihre Registrierung.' ; fi
2026-10-04 11:40:25 +02:00
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
2026-10-04 12:20:27 +02:00
if [[ -z $MANAGER ]] ; then ask 'Erreichbare Wazuh-Manager-Adresse (IP oder DNS-Name):' ; MANAGER = $REPLY ; fi
2026-10-04 11:40:25 +02:00
[[ $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.'
2026-10-04 11:50:50 +02:00
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.'
2026-10-04 11:40:25 +02:00
fi
2026-10-04 12:20:27 +02:00
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:'
2026-10-04 11:40:25 +02:00
[[ $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 } "
2026-10-04 12:00:24 +02:00
say 'Vorhandene Wazuh-Registrierungen bleiben erhalten. DNS/SSH wurden nur gemaess Auswahl geaendert.'
2026-10-04 11:40:25 +02:00
say 'Enrollment richtet noch keine Discord-Regeln oder vollstaendige Schwachstellenabdeckung ein.'
[[ -z $failed ]]
}
main