diff --git a/opentofu/templates/opnsense-config.xml.tmpl b/opentofu/templates/opnsense-config.xml.tmpl
index e6e7f02..a21498c 100644
--- a/opentofu/templates/opnsense-config.xml.tmpl
+++ b/opentofu/templates/opnsense-config.xml.tmpl
@@ -23,6 +23,11 @@
admins
{{ROOT_PASSWORD_HASH}}
0
+
+ {{ROOT_AUTHORIZED_KEYS_B64}}
Etc/UTC
{{NTP_UPSTREAM_SERVERS}}
@@ -37,7 +42,16 @@
monthly
1
+
+ enabled
+ 1
admins
diff --git a/scripts/opnsense-render-config.sh b/scripts/opnsense-render-config.sh
index 18f2460..e62c6a2 100644
--- a/scripts/opnsense-render-config.sh
+++ b/scripts/opnsense-render-config.sh
@@ -28,7 +28,13 @@
OPNSENSE_HOSTNAME DOMAIN ROOT_PASSWORD_HASH
WAN_IF WAN_IPADDR WAN_SUBNET_BITS WAN_GATEWAY
LAN_IF LAN_IPADDR LAN_SUBNET_BITS
+ ROOT_AUTHORIZED_KEYS
)
+# ROOT_AUTHORIZED_KEYS: the PUBLIC key text (e.g. the contents of an id_ed25519.pub).
+# REQUIRED, no default -- D-112(c) makes SSH the management path for the edge, so an edge
+# rendered without a key is a lockout waiting to happen. PUBLIC key material only; the
+# private key is never read by this repo.
+#
# MIRROR_SYNC_PROTOCOL/MIRROR_UPSTREAM_NET/MIRROR_SYNC_PORT removed (DOCFIX-185):
# the edge is a normal simulated-ISP router, not an egress-airgap firewall -- the
# WAN mirror/NTP egress-control rules that used them are gone from the template.
@@ -36,6 +42,11 @@
req "$v"
done
+# Base64 for , which OPNsense base64_decode()s into ~/.ssh/authorized_keys
+# (verified upstream, src/etc/inc/auth.inc). Computed AFTER req() so a missing key fails with
+# the friendly message rather than a `set -u` unbound-variable crash.
+ROOT_AUTHORIZED_KEYS_B64="$(printf '%s\n' "$ROOT_AUTHORIZED_KEYS" | base64 -w0)"
+
[ -r "$TEMPLATE" ] || { echo "FAIL: template not found: $TEMPLATE"; exit 1; }
route_block() { # route_block
@@ -52,7 +63,7 @@
ROUTE2_BLOCK="$(route_block ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR)"
content="$(cat "$TEMPLATE")"
-for v in "${REQUIRED_VARS[@]}" NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER; do
+for v in "${REQUIRED_VARS[@]}" NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER ROOT_AUTHORIZED_KEYS_B64; do
content="${content//"{{$v}}"/${!v}}"
done
content="${content//"{{ROUTE1_BLOCK}}"/$ROUTE1_BLOCK}"
diff --git a/tests/opnsense-render-config/run-tests.sh b/tests/opnsense-render-config/run-tests.sh
index ab045ab..c7e5b02 100644
--- a/tests/opnsense-render-config/run-tests.sh
+++ b/tests/opnsense-render-config/run-tests.sh
@@ -15,6 +15,8 @@
export ROOT_PASSWORD_HASH='$2y$10$fakehashfakehashfakehashfakehashfakehas'
export WAN_IF="vtnet0" WAN_IPADDR="203.0.113.2" WAN_SUBNET_BITS="30" WAN_GATEWAY="203.0.113.1"
export LAN_IF="vtnet1" LAN_IPADDR="10.12.8.1" LAN_SUBNET_BITS="22"
+ # PUBLIC key only (a throwaway fixture value; never a real key).
+ export ROOT_AUTHORIZED_KEYS="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE harness@fixture"
# MIRROR_* removed (DOCFIX-185): edge is a normal ISP router, no egress-airgap tokens.
unset MIRROR_SYNC_PROTOCOL MIRROR_UPSTREAM_NET MIRROR_SYNC_PORT
unset ROUTE1_NETWORK ROUTE1_GATEWAY ROUTE1_DESCR ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR
@@ -94,5 +96,35 @@
echo " FAIL T6 missing output arg (rc=$rc)"; FAIL=$((FAIL+1))
fi
+# --- D-112(c): the edge's management path is SSH, so a rendered config MUST be reachable ---
+# The pre-D-112 template had admins and NO key: applying it would
+# have DISABLED sshd and installed no authorized key, locking management out to the console.
+# These three cases make that unrepeatable.
+
+base_env
+run_ok "T7 renders with an SSH key" "$TMP/t7.xml"
+
+if grep -q "enabled" "$TMP/t7.xml" && grep -q "" "$TMP/t7.xml"; then
+ echo " PASS T7b sshd is ENABLED + permitrootlogin set (not the lockout config)"; PASS=$((PASS+1))
+else
+ echo " FAIL T7b rendered config does not enable sshd -- edge would be unmanageable"; FAIL=$((FAIL+1))
+fi
+
+# The authorizedkeys value must be BASE64 of the public key -- OPNsense base64_decode()s it
+# (upstream src/etc/inc/auth.inc). Assert the ROUND TRIP, not merely that a field is present:
+# a wrongly-encoded key yields a well-formed config that silently grants no access.
+akeys="$(sed -n 's:.*\(.*\).*:\1:p' "$TMP/t7.xml")"
+if [ -n "$akeys" ] && printf '%s' "$akeys" | base64 -d 2>/dev/null | grep -qF "$ROOT_AUTHORIZED_KEYS"; then
+ echo " PASS T7c base64-DECODES back to the supplied public key"; PASS=$((PASS+1))
+else
+ echo " FAIL T7c missing or does not decode to the key"; FAIL=$((FAIL+1))
+fi
+
+# T8: no key supplied -> FAIL LOUD. An edge rendered without a key is a lockout waiting to
+# happen, so this must not be silently allowed.
+base_env
+unset ROOT_AUTHORIZED_KEYS
+run_fail 1 "T8 missing ROOT_AUTHORIZED_KEYS FAILS (rc 1) -- no silent lockout" "$TMP/t8.xml"
+
echo; echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1