fix(access): ban guest-agent as access channel — SSH only [#403]

Previous sessions used `qm guest exec` to back-door SSH keys into ~30 VMs,
bypassing sshd's audit trail in an ITAR/CMMC environment. Wire the ban deep
so it cannot recur:

- tests/remote.sh: remove the vm-guest mode + qm-guest-exec path entirely
- scripts/check-rules.sh: rule #11 fails on any `qm guest exec` / `vm-guest`
  pattern in code (scans .sh/.bash/.py; docs may describe the ban freely)
- AGENTS.md: codify "Access-channel policy: SSH only" as non-negotiable;
  add "Questions" rule banning harness question tools (use questions-v1.md)
- tests/vm-validation.sh: drop guest-agent key re-injection; SSH-only
- proxmox/perf/scripts/perf-matrix.sh + deploy-tuned-guests.sh: convert
  guest-agent execution to SSH (vmroot) now that VMs have key + sudo
- bootstrap-all.sh: re-target the 8 remaining locked-out systems with
  correct users/methods; print a console one-liner for publickey-only Pis

Guest-agent remains installable/checkable for Proxmox state visibility —
never as an execution or key-delivery path.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-10 16:03:16 -05:00
parent 59f155d06e
commit 1d7c74676c
8 changed files with 152 additions and 139 deletions
+33
View File
@@ -41,6 +41,29 @@ There are no exceptions to this rule.**
or directly. Asking is always acceptable. Overstepping is never
acceptable.
### Access-channel policy: SSH only (NON-NEGOTIABLE)
**The qemu guest-agent is NEVER an access, execution, or key-delivery
channel.** SSH (`sshd`) is the only approved remote access path. Every
command must flow through sshd so it is captured by the standard
auth/logging/audit infrastructure. This is an ITAR/CMMC/TS/SCI
environment — there is no back-door exception, ever.
- **Forbidden:** `qm guest exec` (runs arbitrary commands inside a guest
over an unaudited channel) and any wrapper around it (e.g. a `vm-guest`
mode). This includes using guest-agent to *deliver* an SSH key, even if
the resulting SSH login is itself audited — the delivery bypassed audit.
- **Allowed:** installing or checking qemu-guest-agent for its intended
purpose — letting Proxmox see guest state (`qm guest cmd <id> ping`,
`agent: 1` config, `apt install qemu-guest-agent`). Visibility only;
never execution.
- **Enforced mechanically:** `scripts/check-rules.sh` rule #11 fails on
any `qm guest exec` / `vm-guest` pattern in code. `tests/remote.sh` has
no guest-exec mode.
- **If a system is locked out** (no SSH key, no guest-exec path): surface
it to the user. Do NOT improvise an alternate back-channel. The user
authorizes the unblock method (console login, credential, etc.).
### What this means in practice
- Discovered a typo in a config during approved work? **Finish the approved
@@ -92,6 +115,16 @@ for a full audit or `--fast` for pre-commit speed. Bypass with `--no-verify`
- **Use sub-agents as subcontractors:** scoped spec in, distilled deliverable out.
Never read 10+ files sequentially; batch into agent calls.
## Questions (NON-NEGOTIABLE)
**NEVER use a harness "question"/"ask user" tool** (structured prompts,
modal forms, tabbed questions). Banned across every project, every harness.
They are not portable, not version-controlled, and bypass the git record.
**All questions go in [questions-v1.md](questions-v1.md)** — write the
question; the human edits the answer inline in the same file. Version up
per round. Synthesize resolved Q&A into Discourse (decisions) and Redmine
(work items). See `BASELINE-PROMPT.md` §10.
## Documentation policy (IMPORTANT)
**Discourse is the canonical source of truth for all knowledge documentation.**
+4 -1
View File
@@ -5,4 +5,7 @@ A commit is blocked while any task below remains unchecked.
## Current Tasks
- [x] Create e1000e offload fix script (repo only, not deployed yet)
- [x] Wire guest-agent-as-access ban: strip vm-guest from remote.sh, add check-rules.sh rule #11, codify in AGENTS.md [#403]
- [x] Convert vm-validation.sh + perf-matrix.sh + deploy-tuned-guests.sh from guest-agent to SSH
- [x] Rewrite bootstrap-all.sh for remaining 8 locked-out systems (user runs interactively)
- [x] Ban harness question-tool in meta + project AGENTS.md (use questions-v1.md only)
+39 -30
View File
@@ -1,14 +1,17 @@
#!/usr/bin/bash
# bootstrap-all.sh — push agent key + sudo to every remaining NO-KEY system.
# bootstrap-all.sh — push agent SSH key + passwordless sudo to remaining systems.
#
# SSH is the ONLY approved access channel (see AGENTS.md "Access-channel
# policy: SSH only"). This script reaches systems that still allow password
# auth over sshd. Systems that reject password auth (publickey-only) cannot
# be reached this way — see the CONSOLE-ONLY section printed at the end.
#
# Two escalation methods:
# sudo → Ubuntu systems (no root password, localuser has sudo)
# su → Debian systems (root has a password)
# sudo → Ubuntu-style systems (no root pw; localuser has sudo)
# su → Debian-style systems (root has a password)
#
# Passes AGENT_USER so agent-bootstrap.sh pushes the key + sudo to the
# correct unprivileged user per system type.
#
# You'll enter passwords interactively. Idempotent: safe to re-run.
# Passes AGENT_USER so agent-bootstrap.sh targets the correct unprivileged
# user. You enter passwords interactively. Idempotent: safe to re-run.
set -u
cd "$(dirname "$0")" || exit 1
@@ -39,35 +42,41 @@ run_with_su() {
echo
}
# === Ubuntu: localuser + sudo ===
echo "### Ubuntu (localuser + sudo) ###"
# === localuser + sudo (password auth confirmed on) ===
echo "### localuser + sudo ###"
echo
run_with_sudo tsys-siem 100.72.35.113 localuser
run_with_sudo sectestbed-cloudron 100.97.140.105 localuser
run_with_sudo kali-tsys 100.82.30.115 localuser
run_with_sudo pfvsvrpi 100.91.151.113 localuser
run_with_sudo tsys-siem 100.72.35.113 localuser
run_with_sudo sectestbed-cloudron 100.97.140.105 localuser
# === Debian: subodev + su ===
echo "### Debian: subodev + su ###"
# === subodev/ultixfield + su (password auth confirmed on) ===
echo "### subodev/ultixfield + su ###"
echo
run_with_su subopi3 100.93.17.77 subodev subodev
run_with_su subopi-dev-3 100.64.231.65 subodev subodev
run_with_su subopi-dev-4 100.65.224.85 subodev subodev
# === Debian: ultixfield + su ===
echo "### Debian: ultixfield + su ###"
echo
run_with_su ultix-field 100.115.233.124 ultixfield ultixfield
run_with_su subopi-dev-3 100.64.231.65 subodev subodev
run_with_su ultix-field 100.115.233.124 ultixfield ultixfield
echo "========================================"
echo "Done. Tell the agent to re-run access-matrix.sh to verify."
echo "Done. Re-run access-matrix.sh to verify."
echo "========================================"
echo "Blocked — need console work (separate ticket):"
echo " stlpc-3dscanner — bring online + rename [#417]"
echo
echo "CONSOLE-ONLY — sshd rejects password auth (publickey-only)."
echo "Log in at the physical console (as root, or user then su/sudo)"
echo "and paste this ONE line:"
echo
cat <<'ONELINER'
KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIWms/uCXnjjo4KyxHBcYI2TDHe8OZ2wle6W/0hSRQLu reachableceo@ultix-streaming'; for u in root localuser subodev ultixfield; do getent passwd "$u">/dev/null||continue; H=$(getent passwd "$u"|cut -d: -f6); mkdir -p "$H/.ssh"; chmod 700 "$H/.ssh"; AK="$H/.ssh/authorized_keys"; touch "$AK"; chmod 600 "$AK"; grep -qF "$KEY" "$AK"||echo "$KEY">>"$AK"; chown -R "$u": "$H/.ssh"; done; for u in localuser subodev ultixfield; do getent passwd "$u">/dev/null&&[ -d /etc/sudoers.d ]&&{ echo "$u ALL=(ALL) NOPASSWD:ALL">/etc/sudoers.d/010-agent; chmod 440 /etc/sudoers.d/010-agent; }; done; echo DONE
ONELINER
echo
echo " pfvsvrpi (localuser) — Raspberry Pi"
echo " subopi3 (subodev) — Raspberry Pi"
echo " subopi-dev-4 (subodev) — Raspberry Pi"
echo "========================================"
echo "Excluded (managed via API, not SSH):"
echo " tsys-ucs-01, tsys-ucs-02"
echo "Deferred (separate ticket):"
echo " stlp-3dscanner — rename + bring online first [#417]"
echo "========================================"
echo "2FA by design (leave alone):"
echo " sectestbed-sandbox"
echo "By design (leave alone):"
echo " sectestbed-sandbox — 2FA enforced"
echo "========================================"
echo "Excluded by policy (no SSH access):"
echo " pfv-bms (API), tsys-cloudron (prod revenue),"
echo " tsys-ucs-01/02 (API-managed), tsys-umbrel (treasury)"
echo "========================================"
+22 -18
View File
@@ -1,32 +1,36 @@
#!/usr/bin/bash
# Deploy tuned inside VMs via qm guest exec (no SSH needed)
# Uses Proxmox qemu-guest-agent for out-of-band command execution
# Deploy tuned inside VMs over SSH (sshd is the only approved access channel —
# see AGENTS.md "Access-channel policy: SSH only"). Formerly used the
# qemu guest-agent channel; converted to SSH now that all VMs have key + sudo.
set -uo pipefail
# Profile mapping: most VMs get throughput-performance, k8s/network-sensitive get network-latency
PROFILE="${1:-throughput-performance}"
REMOTE_SH="${REMOTE_SH:-$(cd "$(dirname "$0")/../../.." && pwd)/tests/remote.sh}"
VM_USER="${VM_USER:-localuser}"
deploy_vm() {
local host="$1" vmid="$2" name="$3"
# name is the Tailscale hostname (SSH target); vmid retained for reference.
echo -n " VMID $vmid ($name) on $host: "
PROX_HOST=$host bash tests/remote.sh prox "
# Check if guest agent is available
if ! qm guest cmd \$vmid ping >/dev/null 2>&1; then
echo 'NO GUEST AGENT - skip'
exit 0
fi
# Install tuned
result=\$(qm guest exec \$vmid --timeout 120 -- /bin/sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>/dev/null; DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tuned 2>/dev/null; tuned-adm profile $PROFILE 2>/dev/null; systemctl enable tuned 2>/dev/null; systemctl restart tuned 2>/dev/null; tuned-adm active 2>/dev/null' 2>/dev/null)
if echo \"\$result\" | grep -q 'Current active'; then
echo \"\$result\" | grep -o 'Current active.*' | head -1
else
echo 'INSTALL FAILED (apt issue or no network)'
fi
" 2>&1
local result
result=$(VM_IP="$name" VM_USER="$VM_USER" bash "$REMOTE_SH" vmroot \
"DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>/dev/null; \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tuned 2>/dev/null; \
tuned-adm profile $PROFILE 2>/dev/null; \
systemctl enable --now tuned 2>/dev/null; \
tuned-adm active 2>/dev/null" </dev/null 2>&1)
if echo "$result" | grep -q 'Current active'; then
echo "$result" | grep -o 'Current active.*' | head -1
elif echo "$result" | grep -qi 'permission denied\|no route\|timed out'; then
echo "SSH FAILED (no key/no sudo) — run bootstrap-all.sh first"
else
echo "INSTALL FAILED (apt issue or no network)"
fi
}
echo "============================================"
echo " Deploying tuned ($PROFILE) to VMs"
echo " Deploying tuned ($PROFILE) to VMs via SSH"
echo "============================================"
echo ""
@@ -55,7 +59,7 @@ deploy_vm pfv-tsys6 604 tsys-proxmox-mailgw-01
echo "--- pfv-tsys7 ---"
deploy_vm pfv-tsys7 701 pfv-k8s-wnode-tsys7
deploy_vm pfv-tsys7 702 hfnoc-uisp
deploy_vm pfv-tsys7 703 rr-middleware-01
deploy_vm pfv-tsys7 703 pfv-rr-middleware-01
deploy_vm pfv-tsys7 705 pfv-k8s-cnode2
deploy_vm pfv-tsys7 706 kali-rd
deploy_vm pfv-tsys7 707 tsys-siem
+23 -24
View File
@@ -10,7 +10,8 @@
# Prerequisites:
# - iperf3 installed on all hosts (systemd service: iperf3-server)
# - iperf3 installed inside guest VMs
# - Guest agent enabled on VMs for remote command execution
# - SSH key + passwordless sudo on all guest VMs (remote.sh; sshd is the
# only approved access channel — see AGENTS.md)
#
# Usage:
# bash perf-matrix.sh # run all tests
@@ -41,19 +42,19 @@ DATANET_IP[pfv-tsys9]="10.100.100.9"
HOSTS="pfv-tsys1 pfv-tsys3 pfv-tsys4 pfv-tsys5 pfv-tsys6 pfv-tsys7 pfv-tsys9"
# --- k8s/ultix VM guest-agent targets ---
# Format: prox_host:vmid:datanet_ip:label
# --- k8s/ultix VM targets (SSH for control; datanet_ip for iperf traffic) ---
# Format: prox_host:vmid:datanet_ip:ssh_host:label
GUEST_TARGETS="
pfv-tsys1:102:10.100.100.10:cnode1
pfv-tsys7:705:10.100.100.11:cnode2
pfv-tsys6:603:10.100.100.12:cnode3
pfv-tsys3:313:10.100.100.13:wnode-tsys3
pfv-tsys5:500:10.100.100.14:wnode-tsys5
pfv-tsys6:601:10.100.100.15:wnode-tsys6
pfv-tsys7:701:10.100.100.16:wnode-tsys7
pfv-tsys9:905:10.100.100.17:wnode-tsys9
pfv-tsys5:5111:10.100.100.18:ultix-streaming
pfv-tsys5:5112:10.100.100.19:ultix-offstage
pfv-tsys1:102:10.100.100.10:pfv-k8s-cnode1:cnode1
pfv-tsys7:705:10.100.100.11:pfv-k8s-cnode2:cnode2
pfv-tsys6:603:10.100.100.12:pfv-k8s-cnode3:cnode3
pfv-tsys3:313:10.100.100.13:pfv-k8s-wnode-tsys3:wnode-tsys3
pfv-tsys5:500:10.100.100.14:pfv-k8s-wnode-tsys5:wnode-tsys5
pfv-tsys6:601:10.100.100.15:pfv-k8s-wnode-tsys6:wnode-tsys6
pfv-tsys7:701:10.100.100.16:pfv-k8s-wnode-tsys7:wnode-tsys7
pfv-tsys9:905:10.100.100.17:pfv-k8s-wnode-tsys9:wnode-tsys9
pfv-tsys5:5111:10.100.100.18:ultix-streaming:ultix-streaming
pfv-tsys5:5112:10.100.100.19:ultix-offstage:ultix-offstage
"
DURATION="${DURATION:-3}" # seconds per iperf3 test
@@ -134,14 +135,13 @@ test_guests() {
# Start iperf3 server on the first guest (cnode1)
local server_entry
server_entry=$(echo "$GUEST_TARGETS" | head -2 | tail -1)
local s_host s_vid s_ip s_label
s_host=$(echo "$server_entry" | cut -d: -f1)
s_vid=$(echo "$server_entry" | cut -d: -f2)
local s_ip s_ssh s_label
s_ip=$(echo "$server_entry" | cut -d: -f3)
s_label=$(echo "$server_entry" | cut -d: -f4)
s_ssh=$(echo "$server_entry" | cut -d: -f4)
s_label=$(echo "$server_entry" | cut -d: -f5)
echo " Starting iperf3 server on $s_label ($s_ip)..."
PROX_HOST="$s_host" VM_ID="$s_vid" bash "$REMOTE_SH" vm-guest \
VM_IP="$s_ssh" bash "$REMOTE_SH" vmroot \
'pkill iperf3 2>/dev/null; iperf3 -s -D' >/dev/null 2>&1
sleep 1
@@ -150,15 +150,14 @@ test_guests() {
while read -r entry; do
[ -z "$entry" ] && continue
local c_host c_vid c_ip c_label
c_host=$(echo "$entry" | cut -d: -f1)
c_vid=$(echo "$entry" | cut -d: -f2)
local c_ip c_ssh c_label
c_ip=$(echo "$entry" | cut -d: -f3)
c_label=$(echo "$entry" | cut -d: -f4)
c_ssh=$(echo "$entry" | cut -d: -f4)
c_label=$(echo "$entry" | cut -d: -f5)
[ "$c_ip" = "$s_ip" ] && continue
local result
result=$(PROX_HOST="$c_host" VM_ID="$c_vid" bash "$REMOTE_SH" vm-guest \
result=$(VM_IP="$c_ssh" bash "$REMOTE_SH" vmroot \
"iperf3 -c $s_ip -t $DURATION -P $STREAMS -f m 2>&1" </dev/null \
| awk '/SUM.*receiver/{printf "%.0f", $6}')
if [ -n "$result" ]; then
@@ -171,7 +170,7 @@ test_guests() {
done <<< "$GUEST_TARGETS"
# Cleanup
PROX_HOST="$s_host" VM_ID="$s_vid" bash "$REMOTE_SH" vm-guest \
VM_IP="$s_ssh" bash "$REMOTE_SH" vmroot \
'pkill iperf3' >/dev/null 2>&1
echo ""
+21
View File
@@ -242,4 +242,25 @@ if [ "$RULE_FAST" = false ] && [ -x scripts/test.sh ]; then
fi
fi
# ----------------------------------------------------------------------------
# 11. Access-channel policy — qemu guest-agent must NEVER be used as an
# execution or key-delivery channel. SSH (sshd) is the ONLY approved
# remote access path; all commands must be logged through standard
# auth/audit infrastructure. ITAR/CMMC environment — non-negotiable.
# Allowed: installing/checking qemu-guest-agent for Proxmox state
# visibility. Forbidden: `qm guest exec` + any `vm-guest` wrapper.
# Scans CODE only (not .md) so docs may describe the ban.
# ----------------------------------------------------------------------------
$RULE_VERBOSE && log_step "Access-channel policy (no guest-agent exec)"
GA_HITS="$(grep -rnE 'qm guest exec|vm-guest|_vm_guest' \
--include='*.sh' --include='*.bash' --include='*.py' \
. 2>/dev/null | grep -vE 'scripts/check-rules\.sh|/archive/' || true)"
if [ -z "$GA_HITS" ]; then
check "No guest-agent exec / vm-guest access patterns in code" "pass"
else
GA_COUNT="$(printf '%s\n' "$GA_HITS" | grep -c . || true)"
$RULE_VERBOSE && printf '%s\n' "$GA_HITS" | sed 's/^/ /'
check "${GA_COUNT} guest-agent exec / vm-guest reference(s) — SSH-only access policy (AGENTS.md)" "fail"
fi
print_summary_and_exit
-30
View File
@@ -30,8 +30,6 @@ PROX_HOST="${PROX_HOST:-pfv-tsys5}"
PROX_USER="${PROX_USER:-root}"
VM_IP="${VM_IP:-192.168.3.50}"
VM_USER="${VM_USER:-localuser}"
VM_ID="${VM_ID:-}"
GUEST_TIMEOUT="${GUEST_TIMEOUT:-900}"
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
@@ -55,33 +53,6 @@ _copy() {
fi
}
# Out-of-band VM access via the Proxmox qemu-guest-agent. This runs commands
# as root inside the VM and does NOT depend on SSH, so it works even after
# secharden-ssh replaces authorized_keys and secharden-2fa enforces
# publickey+keyboard-interactive (which blocks non-interactive SSH).
GUEST_PARSER="/root/.knel-guest-parse.py"
GUEST_PARSER_SRC="import sys, json
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(3)
sys.stdout.write(d.get('out-data', '') or '')
sys.stderr.write(d.get('err-data', '') or '')
ec = d.get('exitcode', 1)
sys.exit(ec if ec is not None else 1)"
_ensure_guest_parser() {
if _prox "test -f '$GUEST_PARSER'" >/dev/null 2>&1; then return 0; fi
printf '%s\n' "$GUEST_PARSER_SRC" | _prox "cat > '$GUEST_PARSER'" >/dev/null 2>&1
}
_vm_guest() {
[ -n "$VM_ID" ] || die "vm-guest requires VM_ID"
_ensure_guest_parser
local cmdb64; cmdb64="$(printf '%s' "$*" | base64 -w0)"
_prox "qm guest exec $VM_ID --timeout ${GUEST_TIMEOUT} -- /bin/sh -c 'echo $cmdb64 | base64 -d | /bin/sh' 2>/dev/null | python3 '$GUEST_PARSER'"
}
mode="${1:-}"; shift || true
case "$mode" in
prox) [ "$#" -ge 0 ] || die "need command"; _prox "$*" ;;
@@ -91,7 +62,6 @@ case "$mode" in
vm-file) [ -f "${1:-}" ] || die "need local script file"; _vm "bash -s" < "$1" ;;
vm-copy) [ -f "${1:-}" ] || die "need local file"; _copy "${VM_USER}@${VM_IP}" "$1" "${2:-}" ;;
prox-copy) [ -f "${1:-}" ] || die "need local file"; _copy "${PROX_USER}@${PROX_HOST}" "$1" "${2:-}" ;;
vm-guest) [ "$#" -ge 1 ] || die "need command"; _vm_guest "$*" ;;
""|-h|--help|help) sed -n '2,40p' "${BASH_SOURCE[0]}" >&2; exit 0 ;;
*) die "unknown mode '$mode'. Run '$0 help'." ;;
esac
+10 -36
View File
@@ -54,10 +54,9 @@ VM_ID="${VM_ID:-}"
REPO_URL="${REPO_URL:-https://git.knownelement.com/KNEL/KNELServerBuild.git}"
REMOTE_REPO="${REMOTE_REPO:-KNELServerBuild}"
SNAP_PREFIX="${SNAP_PREFIX:-pre-knel-deploy}"
ACCESS_PUBKEY="${ACCESS_PUBKEY:-$HOME/.ssh/id_ed25519.pub}"
# Re-inject the validation pubkey after each deploy (secharden-ssh replaces
# authorized_keys with the managed production key set, locking out the
# bootstrap/dev key). Set RESTORE_ACCESS=0 to disable.
# SSH-only access policy: if SSH access is lost after a deploy, the agent does
# NOT re-inject keys via a back-channel. Set RESTORE_ACCESS=1 to have the
# script check and warn (it cannot self-restore).
RESTORE_ACCESS="${RESTORE_ACCESS:-1}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -80,7 +79,6 @@ die() { log "ERROR: $*"; exit 1; }
vm() { bash "$REMOTE" vm "$@"; } # as $VM_USER (SSH)
vmroot() { bash "$REMOTE" vmroot "$@"; } # as root via sudo (SSH)
vmfile() { bash "$REMOTE" vm-file "$@"; } # run local script on VM (SSH)
vmguest() { bash "$REMOTE" vm-guest "$@"; } # as root via guest agent (no SSH/2FA)
prox() { bash "$REMOTE" prox "$@"; } # as $PROX_USER on Proxmox
require_vm_id() {
@@ -104,45 +102,21 @@ wait_for_vm_ssh() {
resolve_remote_repo() {
local p
p="$(vm "cd ~/${REMOTE_REPO} 2>/dev/null && pwd" 2>/dev/null)"
[[ -n "$p" ]] || p="$(vmguest "cd ~${VM_USER}/${REMOTE_REPO} 2>/dev/null && pwd" 2>/dev/null)"
printf '%s' "$p"
}
# Re-inject the validation pubkey into ~$VM_USER/.ssh/authorized_keys OUT OF
# BAND via the Proxmox guest agent (qm guest exec runs as root inside the VM
# and does not depend on SSH). This is necessary because secharden-ssh replaces
# authorized_keys with the managed production key set, which would otherwise
# lock out the bootstrap key used to drive validation. No-op if SSH still works.
# SSH-only access policy (AGENTS.md): if SSH access is lost (e.g. secharden-ssh
# replaced authorized_keys), the agent does NOT re-inject keys via guest-agent
# or any other back-channel. Surface the loss for manual restore.
restore_vm_access() {
[[ "$RESTORE_ACCESS" = "1" ]] || { log "RESTORE_ACCESS=0; skipping access restore."; return 0; }
[[ -f "$ACCESS_PUBKEY" ]] || { log "WARN: ACCESS_PUBKEY not found ($ACCESS_PUBKEY); cannot restore access."; return 0; }
if vm 'true' >/dev/null 2>&1; then
log "SSH access already works; no need to restore."
log "SSH access works; no restore needed."
return 0
fi
log "SSH access lost (expected after secharden-ssh). Restoring via Proxmox guest agent..."
local payload_b64
# Leading newline guards against the managed authorized_keys lacking a
# trailing newline (which would otherwise concatenate two keys into one).
payload_b64="$(printf '\n%s' "$(cat "$ACCESS_PUBKEY")" | base64 -w0)"
prox "qm guest exec $VM_ID -- /bin/sh -c 'echo $payload_b64 | base64 -d >> /home/${VM_USER}/.ssh/authorized_keys'" \
>/dev/null 2>&1 || { log "WARN: guest-agent key append failed."; return 0; }
prox "qm guest exec $VM_ID -- /bin/sh -c 'chown ${VM_USER}:${VM_USER} /home/${VM_USER}/.ssh/authorized_keys; chmod 600 /home/${VM_USER}/.ssh/authorized_keys'" \
>/dev/null 2>&1 || true
if vm 'true' >/dev/null 2>&1; then
log "Access restored."
return 0
fi
# If SSH still fails after re-injecting the key, 2FA is almost certainly the
# cause (secharden-2fa enforces publickey+keyboard-interactive, which no
# non-interactive SSH client can satisfy). That is expected and not fatal:
# the guest agent still gives us full out-of-band access for log fetch and
# the validation suite.
if vmguest 'grep -q "^AuthenticationMethods" /etc/ssh/sshd_config' >/dev/null 2>&1; then
log "SSH requires 2FA (expected after secharden-2fa); using guest agent for further access."
else
log "WARN: access still not working after restore and 2FA not detected. Check sshd_config."
fi
log "WARN: SSH access lost (e.g. secharden-ssh replaced authorized_keys)."
log " Per SSH-only access policy (AGENTS.md), no back-channel re-inject."
log " Restore manually: console login + re-add the bootstrap key, then re-run."
}
# ---------------------------------------------------------------------------