diff --git a/AGENTS.md b/AGENTS.md index e868111..1c4fbf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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.** diff --git a/WORKING.md b/WORKING.md index 417d1c0..c10f2e6 100644 --- a/WORKING.md +++ b/WORKING.md @@ -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) diff --git a/bootstrap-all.sh b/bootstrap-all.sh index b474493..ced5ea5 100644 --- a/bootstrap-all.sh +++ b/bootstrap-all.sh @@ -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 "========================================" diff --git a/proxmox/perf/scripts/deploy-tuned-guests.sh b/proxmox/perf/scripts/deploy-tuned-guests.sh index 5ffb60c..4e93ab0 100644 --- a/proxmox/perf/scripts/deploy-tuned-guests.sh +++ b/proxmox/perf/scripts/deploy-tuned-guests.sh @@ -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" &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 diff --git a/proxmox/perf/scripts/perf-matrix.sh b/proxmox/perf/scripts/perf-matrix.sh index 43a24d6..831acf0 100755 --- a/proxmox/perf/scripts/perf-matrix.sh +++ b/proxmox/perf/scripts/perf-matrix.sh @@ -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 2>&1 echo "" diff --git a/scripts/check-rules.sh b/scripts/check-rules.sh index 7ba3a6a..a96fae2 100755 --- a/scripts/check-rules.sh +++ b/scripts/check-rules.sh @@ -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 diff --git a/tests/remote.sh b/tests/remote.sh index a5819cd..cd1bbfe 100755 --- a/tests/remote.sh +++ b/tests/remote.sh @@ -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 diff --git a/tests/vm-validation.sh b/tests/vm-validation.sh index 518db4a..d2523db 100755 --- a/tests/vm-validation.sh +++ b/tests/vm-validation.sh @@ -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." } # ---------------------------------------------------------------------------