#!/usr/bin/env python3
# scripts/opnsense-console-rebuild.py --domain <libvirt-domain> --pubkey <edge-pubkey-file> [--log <path>]
#
# D-112(c) console bootstrap for a FRESHLY-REBUILT OPNsense DC edge. Drives the serial
# console (virsh console) of a just-deployed pristine edge: catches the factory login
# (root/opnsense), drops to a shell, and runs one PHP payload that
#   (1) enables sshd + permitrootlogin + group admins,
#   (2) installs the site's edge SERVICE pubkey on root, and
#   (3) materialises it with local_user_set() (write_config alone does NOT create
#       /root/.ssh/authorized_keys -- dc0 lesson v).
# After this, key-only SSH to the factory LAN (192.168.1.1) works and the edge can be
# addressed by scripts/opnsense-set-interface-v4.sh.
#
# RUNS ON THE DC RACK HOST (the libvirt host of the inner edge VM). Needs python3 +
# pexpect there (measured present on the VR1 racks). It is SITE-AGNOSTIC: the only
# per-DC inputs are the libvirt domain name and the edge pubkey file -- NOTHING is
# hardcoded, so it replaces the per-DC one-off drivers (d112c-console-<dc>-rebuild.py)
# the dc0 2026-08-02 and dc1 2026-08-07 rebuilds each carried as separate copies.
#
# PROVENANCE: generalised 2026-08-07 from the dc0-rebuild driver
# (docs/audit/dc0-edge-rebuild-20260802.txt SECTION 3) after it was used verbatim (bar
# DOMAIN/PUB) to repair the dc1 edge (SEC-031). Lessons baked in: (i) payload shipped as
# <=160-char base64 chunks -- the serial console wraps long lines; (iii) NO nested-quoted
# one-liners and NO `2>&1` in edge commands -- the edge root shell is tcsh; (iv) the
# bootstrap PHP requires util.inc (shell_safe lives there), not just config.inc.
#
# The edge PUBKEY is PUBLIC material; the private half never touches this tool.
# Exit 0 on BOOTSTRAP-SEQUENCE-COMPLETE; 2 if no known console state is reached.
import argparse, base64, sys, time

try:
    import pexpect
except ImportError:
    sys.exit("FAIL: python3 pexpect is required on the rack (pip install pexpect)")

ap = argparse.ArgumentParser(description="D-112(c) console bootstrap for a rebuilt OPNsense edge")
ap.add_argument("--domain", required=True, help="libvirt domain name of the edge (e.g. vr1-dc1-opnsense)")
ap.add_argument("--pubkey", required=True, help="path to the site edge SERVICE pubkey (public material)")
ap.add_argument("--log", default=None, help="console transcript path (default: ~/opnsense-console-rebuild-<domain>.log)")
a = ap.parse_args()

import os
PUB = open(a.pubkey).read().strip()
if not PUB.startswith("ssh-"):
    sys.exit("FAIL: --pubkey %s does not look like an ssh public key" % a.pubkey)
LOG = a.log or os.path.expanduser("~/opnsense-console-rebuild-%s.log" % a.domain)

PHP = '''<?php
require_once("config.inc");
require_once("util.inc");
require_once("auth.inc");
global $config;
$config["system"]["ssh"]["enabled"] = "enabled";
$config["system"]["ssh"]["permitrootlogin"] = "1";
$config["system"]["ssh"]["group"] = "admins";
$pub = "%s";
$users = &$config["system"]["user"];
if (isset($users["name"])) {
    if ($users["name"] === "root") { $users["authorizedkeys"] = base64_encode($pub); }
} else {
    foreach ($users as &$u) {
        if (isset($u["name"]) && $u["name"] === "root") { $u["authorizedkeys"] = base64_encode($pub); }
    }
    unset($u);
}
write_config("D-112(c) console bootstrap: enable ssh + install service key (rebuild)");
echo "CONFIG-WRITTEN\\n";
$u2 = &$config["system"]["user"];
$list = isset($u2["name"]) ? array($u2) : $u2;
foreach ($list as $usr) {
    if (isset($usr["name"]) && $usr["name"] === "root") {
        echo "AK-IN-CONFIG=" . (isset($usr["authorizedkeys"]) ? strlen($usr["authorizedkeys"]) : 0) . "\\n";
        local_user_set($usr);
        echo "USER-MATERIALIZED\\n";
    }
}
''' % PUB

c = pexpect.spawn("virsh -c qemu:///system console %s --force" % a.domain, encoding="utf-8", timeout=45)
c.logfile_read = open(LOG, "w")
try:
    c.expect("Escape character", timeout=15)
except pexpect.TIMEOUT:
    pass

state = None
for _ in range(6):
    c.sendcontrol("c"); c.sendline("")
    try:
        state = c.expect(["login: ?", "Enter an option", r"root@OPNsense:.*#"], timeout=10)
        break
    except pexpect.TIMEOUT:
        continue
if state is None:
    print("NO KNOWN CONSOLE STATE; see log"); sys.exit(2)
print("CONSOLE-STATE=%d" % state)
if state == 0:
    c.sendline("root"); c.expect("Password:"); c.sendline("opnsense")
    c.expect("Enter an option"); c.sendline("8"); c.expect(r"root@OPNsense:.*#")
elif state == 1:
    c.sendline("8"); c.expect(r"root@OPNsense:.*#")

PROMPT = r"root@OPNsense:.*#"
def sh(cmd, exp=PROMPT):
    c.sendline(cmd); c.expect(exp)

sh("grep -c authorizedkeys /conf/config.xml")          # BEFORE-state, proves factory start
sh("ls -la /root/.ssh/ 2>&1 | tail -3")
sh("grep -c shell_safe /usr/local/etc/inc/util.inc")   # lesson iv: shell_safe lives in util.inc

b64 = base64.b64encode(PHP.encode()).decode()
sh("rm -f /tmp/b.b64 /tmp/b.php")
for ch in [b64[i:i+160] for i in range(0, len(b64), 160)]:  # lesson i: console wraps long lines
    sh("echo %s >> /tmp/b.b64" % ch)
sh("openssl base64 -d < /tmp/b.b64 > /tmp/b.php")
sh("php -l /tmp/b.php", "No syntax errors"); c.expect(PROMPT)
c.sendline("php /tmp/b.php")
c.expect("CONFIG-WRITTEN", timeout=60)
c.expect("USER-MATERIALIZED", timeout=60)
c.expect(PROMPT)

sh("ls -la /root/.ssh/ 2>&1 | tail -3")                # AFTER-state, the artifact
sh("grep -c authorizedkeys /conf/config.xml")
sh("configctl openssh restart")
time.sleep(5)
sh("rm -f /tmp/b.b64 /tmp/b.php")
c.sendline("exit"); time.sleep(1); c.sendcontrol("]")
print("BOOTSTRAP-SEQUENCE-COMPLETE (transcript: %s)" % LOG)
