199 lines
7.8 KiB
Bash
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"
|