#!/usr/bin/env bash set -Eeuo pipefail readonly AGENT_USER="ghost-agent" readonly AGENT_UID="${GHOSTNET_AGENT_UID:-1000}" readonly ADMIN_USER="ghost" readonly ADMIN_UID="${GHOSTNET_ADMIN_UID:-1001}" log() { printf '[ghostnet-lxc-baseline] %s\n' "$*" } die() { printf '[ghostnet-lxc-baseline] ERROR: %s\n' "$*" >&2 exit 1 } [[ "${EUID}" -eq 0 ]] || die "must run as root on the Proxmox host" command -v pct >/dev/null 2>&1 || die "pct not found; run this script on Proxmox" [[ "$AGENT_UID" =~ ^[0-9]+$ ]] || die "GHOSTNET_AGENT_UID must be numeric" [[ "$ADMIN_UID" =~ ^[0-9]+$ ]] || die "GHOSTNET_ADMIN_UID must be numeric" [[ "$AGENT_UID" != "$ADMIN_UID" ]] || die "agent and administrator UIDs must differ" ctid="${CTID:-${1:-}}" [[ "$ctid" =~ ^[0-9]+$ ]] || die "CTID must be supplied by Community Scripts or as the first argument" pct config "$ctid" >/dev/null 2>&1 || die "CT ${ctid} does not exist" pct config "$ctid" | grep -qx 'unprivileged: 1' || die "CT ${ctid} is not unprivileged" [[ "$(pct status "$ctid")" == 'status: running' ]] || die "CT ${ctid} is not running" log "preparing unprivileged CT ${ctid} (${HN:-unknown hostname}, ${IP:-unknown IP})" pct exec "$ctid" -- /bin/bash -s -- \ "$AGENT_USER" "$AGENT_UID" "$ADMIN_USER" "$ADMIN_UID" <<'GUEST_SCRIPT' set -Eeuo pipefail agent_user="$1" agent_uid="$2" admin_user="$3" admin_uid="$4" log() { printf '[ghostnet-lxc-baseline/guest] %s\n' "$*" } die() { printf '[ghostnet-lxc-baseline/guest] ERROR: %s\n' "$*" >&2 exit 1 } [[ "${EUID}" -eq 0 ]] || die "guest stage is not running as root" [[ -r /etc/os-release ]] || die "/etc/os-release is missing" # shellcheck disable=SC1091 source /etc/os-release [[ "${ID:-}" == "debian" ]] || die "unsupported guest OS: ${ID:-unknown}" [[ "${VERSION_ID:-0}" =~ ^[0-9]+$ ]] || die "invalid Debian VERSION_ID: ${VERSION_ID:-missing}" (( VERSION_ID >= 12 )) || die "Debian ${VERSION_ID} is too old; Debian 12 or newer is required" validate_identity_slot() { local user="$1" local expected_uid="$2" local current_uid="" local current_gid="" local current_group_entry="" local owner_match="" if getent passwd "$user" >/dev/null; then current_uid="$(id -u "$user")" current_gid="$(id -g "$user")" [[ "$current_uid" == "$expected_uid" ]] || die "user ${user} exists with UID ${current_uid}, expected ${expected_uid}" [[ "$current_gid" == "$expected_uid" ]] || die "user ${user} has primary GID ${current_gid}, expected ${expected_uid}" current_group_entry="$(getent group "$user" || true)" [[ -n "$current_group_entry" ]] || die "primary group ${user} is missing" [[ "$(cut -d: -f3 <<<"$current_group_entry")" == "$expected_uid" ]] || die "primary group ${user} is missing or does not use GID ${expected_uid}" return fi getent passwd "$expected_uid" >/dev/null && die "UID ${expected_uid} is already assigned to $(getent passwd "$expected_uid" | cut -d: -f1)" getent group "$user" >/dev/null && die "group name ${user} already exists without its user" getent group "$expected_uid" >/dev/null && die "GID ${expected_uid} is already assigned to $(getent group "$expected_uid" | cut -d: -f1)" owner_match="$(find / -xdev \( -uid "$expected_uid" -o -gid "$expected_uid" \) -print -quit 2>/dev/null || true)" [[ -z "$owner_match" ]] || die "UID/GID ${expected_uid} already owns guest path ${owner_match}" } validate_identity_slot "$agent_user" "$agent_uid" validate_identity_slot "$admin_user" "$admin_uid" missing_packages=() for package in ca-certificates openssh-server sudo; do if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -qx 'install ok installed'; then missing_packages+=("$package") fi done if (( ${#missing_packages[@]} > 0 )); then export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends "${missing_packages[@]}" else log "required base packages are already installed; skipping apt" fi # Debian 13's wtmpdb package no longer ships wtmpdb-rotate.timer. An upgrade # from the original template package can nevertheless leave its old enable # symlink behind, which systemd reports as a failed not-found unit. Remove only # that exact, dangling and unowned package-transition artifact. wtmpdb_timer_unit="/usr/lib/systemd/system/wtmpdb-rotate.timer" wtmpdb_timer_link="/etc/systemd/system/timers.target.wants/wtmpdb-rotate.timer" if [[ -L "$wtmpdb_timer_link" && ! -e "$wtmpdb_timer_unit" ]]; then [[ "$(readlink -- "$wtmpdb_timer_link")" == "$wtmpdb_timer_unit" ]] || die "unexpected target for ${wtmpdb_timer_link}" if dpkg-query --search "$wtmpdb_timer_unit" >/dev/null 2>&1; then die "${wtmpdb_timer_unit} is missing but is still owned by an installed package" fi unlink -- "$wtmpdb_timer_link" systemctl daemon-reload systemctl reset-failed wtmpdb-rotate.timer >/dev/null 2>&1 || true log "removed obsolete dangling wtmpdb-rotate.timer enable link" fi ensure_user() { local user="$1" local uid="$2" if ! getent passwd "$user" >/dev/null; then useradd --uid "$uid" --create-home --shell /bin/bash --user-group "$user" passwd -l "$user" log "created locked account ${user} with UID/GID ${uid}" else log "validated existing account ${user} with UID/GID ${uid}" fi [[ "$(getent passwd "$user" | cut -d: -f6)" == "/home/${user}" ]] || die "unexpected home directory for ${user}" [[ "$(getent passwd "$user" | cut -d: -f7)" == "/bin/bash" ]] || die "unexpected login shell for ${user}" [[ ! -L "/home/${user}" ]] || die "home directory for ${user} is a symlink" [[ ! -L "/home/${user}/.ssh" ]] || die ".ssh directory for ${user} is a symlink" [[ ! -L "/home/${user}/.ssh/authorized_keys" ]] || die "authorized_keys for ${user} is a symlink" install -d -o "$user" -g "$user" -m 700 "/home/${user}" install -d -o "$user" -g "$user" -m 700 "/home/${user}/.ssh" if [[ ! -e "/home/${user}/.ssh/authorized_keys" ]]; then install -o "$user" -g "$user" -m 600 /dev/null "/home/${user}/.ssh/authorized_keys" else chown "$user:$user" "/home/${user}/.ssh/authorized_keys" chmod 600 "/home/${user}/.ssh/authorized_keys" fi } ensure_user "$agent_user" "$agent_uid" ensure_user "$admin_user" "$admin_uid" agent_groups="$(id -Gn "$agent_user")" [[ "$agent_groups" == "$agent_user" ]] || die "${agent_user} has unexpected supplementary groups: ${agent_groups}" usermod -aG sudo "$admin_user" agent_password_state="$(passwd -S "$agent_user" | awk '{print $2}')" admin_password_state="$(passwd -S "$admin_user" | awk '{print $2}')" [[ "$agent_password_state" == "L" ]] || die "${agent_user} password is not locked (state ${agent_password_state})" [[ "$admin_password_state" == "L" || "$admin_password_state" == "P" ]] || die "${admin_user} has unsafe password state ${admin_password_state}" # Debian 13 templates may enable both socket and service activation. Starting # ssh.service through ssh.socket works initially, but a later SIGHUP makes sshd # try to bind port 22 itself and fail with "Cannot bind any address". Use one # deterministic operating model: the classic long-running service only. systemctl disable --now ssh.socket >/dev/null 2>&1 || true systemctl enable ssh.service >/dev/null systemctl restart ssh.service systemctl is-active --quiet ssh.service || die "ssh.service is not active" sshd -t log "guest: $(hostname), Debian ${VERSION_ID}, $(systemd-detect-virt)" log "SSH host key: $(ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)" log "${agent_user}: $(id "$agent_user") / password ${agent_password_state}" log "${admin_user}: $(id "$admin_user") / password ${admin_password_state}" log "authorized_keys files were preserved; no key material was installed" log "SSH hardening and firewall configuration were intentionally not applied" GUEST_SCRIPT log "CT ${ctid} baseline preparation completed" log "next: verify the host key, install unique role keys, test both logins, then harden SSH"