Files

199 lines
7.8 KiB
Bash

#!/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"