UNPKG

underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

2,126 lines 93.2 kB
/** WireGuard overlay and HAProxy edge routing lifecycle. */

import fs from 'fs-extra';
import os from 'node:os';
import nodePath from 'node:path';
import { getConfFilePath, loadConfInstances, loadConfServerJson, resolveDeployList } from '../server/conf.js';
import {
  FORWARD_PROXY,
  forwardProxyCommandFactory,
  forwardProxyConfigFactory,
  forwardProxyNodeCandidatesFactory,
  forwardProxyNodeProbeCommandFactory,
  forwardProxyServerFactory,
  forwardProxyServiceCommandsFactory,
  forwardProxyStartProbeCommandFactory,
  forwardProxyUnitFactory,
} from '../server/forward-proxy.js';
import { loggerFactory } from '../server/logger.js';
import { shellExec } from '../server/process.js';
import {
  homeDirectoryPathFactory,
  journalctlCommandFactory,
  runSystemdCommands,
  systemctlCommandFactory,
  systemdAvailableCommandFactory,
  systemdReloadIfActiveCommandFactory,
  systemdStatusCommandsFactory,
} from '../server/systemd.js';

const logger = loggerFactory(import.meta);

/**
 * @constant UNDERPOST_EDGE
 * @description Fixed identity of the edge subsystem: paths, ports and resource
 * names that both ends of a tunnel have to agree on.
 * @memberof UnderpostWireguard
 */
const UNDERPOST_EDGE = {
  interfaceName: 'wg0',
  listenPort: 51820,
  // The hub's own address, and the subnet a spoke routes back through it. Both
  // sides of every tunnel have to agree on the second one, so it is declared
  // once here rather than defaulted separately at each end.
  cidr: '10.0.0.1/24',
  tunnelCidr: '10.0.0.0/24',
  // Held below the 30s NAT mapping most consumer routers use, so a spoke's
  // outbound session is refreshed before the ISP forgets it exists.
  keepalive: 25,
  wireguardDir: '/etc/wireguard',
  haproxyDir: '/etc/haproxy',
  haproxyConfName: 'haproxy.cfg',
  sniMapName: 'domain2backend.map',
  httpMapName: 'domain2backend-http.map',
  statsSocket: '/var/lib/haproxy/stats',
  natChainPrefix: 'UNDERPOST_WG',
  sysctlPath: '/etc/sysctl.d/99-underpost-wireguard.conf',
  packages: ['wireguard-tools', 'haproxy', 'iptables'],
  httpPort: 80,
  httpsPort: 443,
  // Spoke-side SSH. The public port is per-edge and lives in the registry,
  // because exposing SSH is a decision, not a default.
  sshPort: 22,
  defaultSshForwardPort: 2222,
  // A peer is considered live within this window: WireGuard re-handshakes at
  // least every 120s while traffic flows, so a longer gap means the link is
  // down rather than merely idle.
  handshakeStaleSeconds: 180,
};

/**
 * @method deployIdFactory
 * @description Normalizes a deploy id to the `dd-<conf-id>` convention.
 * @param {string} deployId - Deploy id, with or without the prefix.
 * @returns {string} Prefixed deploy id.
 * @memberof UnderpostWireguard
 */
const deployIdFactory = (deployId) => {
  const value = `${deployId || ''}`.trim();
  // `dd` is the meta id every runner reads as "all of dd.router"; prefixing it
  // would turn it into a deploy that does not exist.
  if (!value || value === 'dd') return value;
  return value.startsWith('dd-') ? value : `dd-${value}`;
};

/**
 * @method deployListFactory
 * @description The deploys whose hostnames a run publishes.
 *
 * `dd` expands through the same `dd.router` read every other runner uses, so
 * the edge routes exactly the set the cluster deploys — a hostname cannot be
 * live in the cluster and absent from the edge because two lists drifted.
 * @param {string} deployId - Deploy id, comma-separated list, or `dd`.
 * @returns {Array<string>} Normalized deploy ids.
 * @memberof UnderpostWireguard
 */
const deployListFactory = (deployId) => {
  const value = `${deployId || ''}`.trim();
  if (!value) return [];
  if (value === 'dd') return resolveDeployList('dd').map(deployIdFactory).filter(Boolean);
  return value.split(',').map(deployIdFactory).filter(Boolean);
};

/**
 * @constant EDGE_STATE_PATH
 * @description Location of the peer registry.
 *
 * Cluster-wide rather than per deploy, and stored beside `dd.router` for that
 * reason: the hub has one interface, one address and one peer table, and those
 * are properties of the machine. A copy per deploy would be several records of
 * one fact, free to disagree. `--deploy-id` selects which hostnames are routed
 * across it, not which tunnel exists.
 *
 * Holds public keys only — the private half never leaves `/etc/wireguard` on
 * the host that generated it.
 * @memberof UnderpostWireguard
 */
const EDGE_STATE_PATH = './engine-private/deploy/conf.wireguard.json';

/**
 * @method peerFactory
 * @description Normalizes one spoke entry, filling the fields a partially
 * written registry may omit.
 *
 * `allowedIPs` defaults to the peer's own tunnel address alone: a spoke routes
 * its LAN only when the registry says so, so a mistyped entry cannot silently
 * claim a subnet another spoke already answers for.
 * @param {object} peer - Raw registry entry.
 * @returns {object} Normalized entry.
 * @memberof UnderpostWireguard
 */
const peerFactory = (peer = {}) => {
  const address = `${peer.address || ''}`.trim();
  const allowedIPs = (Array.isArray(peer.allowedIPs) ? peer.allowedIPs : [])
    .map((entry) => `${entry || ''}`.trim())
    .filter(Boolean);
  return {
    id: `${peer.id || ''}`.trim(),
    address,
    publicKey: `${peer.publicKey || ''}`.trim(),
    allowedIPs: allowedIPs.length > 0 ? allowedIPs : address ? [`${address}/32`] : [],
    hosts: (Array.isArray(peer.hosts) ? peer.hosts : []).map((host) => `${host}`.trim().toLowerCase()).filter(Boolean),
    instances: (Array.isArray(peer.instances) ? peer.instances : []).map((id) => `${id}`.trim()).filter(Boolean),
    default: peer.default === true,
  };
};

/**
 * @method defaultPeerFactory
 * @description The spoke an unmatched hostname falls back to, and the one QUIC
 * is forwarded to.
 *
 * A lone peer is its own fallback: a single-spoke hub has nowhere else to send
 * a hostname, and requiring `"default": true` there would only be ceremony.
 * With several peers and none nominated there is no fallback at all, so nothing
 * is dispatched somewhere arbitrary.
 * @param {Array<object>} [peers] - Normalized registry entries.
 * @returns {?object} The fallback peer, or null.
 * @memberof UnderpostWireguard
 */
const defaultPeerFactory = (peers = []) =>
  peers.find((peer) => peer.default === true) || (peers.length === 1 ? peers[0] : null);

/**
 * @method edgeStateFactory
 * @description Normalizes the registry as a whole, so every consumer reads one
 * shape whether the file exists, is partial, or is absent.
 *
 * `endpoint` and `hubPublicKey` are the pair a spoke needs to rebuild its own
 * interface: the hub it dials, and the identity it expects there. Both are
 * recorded so re-running the setup does not require re-supplying them, which is
 * what makes a spoke's bring-up repeatable and `--wireguard-reinstall` possible
 * on a spoke at all. Neither is secret — a public key is public.
 * @param {object} [state] - Raw registry contents.
 * @returns {object} Normalized registry.
 * @memberof UnderpostWireguard
 */
const edgeStateFactory = (state = {}) => ({
  interfaceName: `${state.interfaceName || UNDERPOST_EDGE.interfaceName}`.trim(),
  role: `${state.role || ''}`.trim(),
  listenPort: Number(state.listenPort) > 0 ? Number(state.listenPort) : UNDERPOST_EDGE.listenPort,
  address: `${state.address || ''}`.trim(),
  endpoint: `${state.endpoint || ''}`.trim(),
  hubPublicKey: `${state.hubPublicKey || ''}`.trim(),
  publicKey: `${state.publicKey || ''}`.trim(),
  // 0 means the edge publishes no SSH port at all, which is the default: an
  // edge that forwards SSH is one someone deliberately opened.
  sshForwardPort: Number(state.sshForwardPort) > 0 ? Number(state.sshForwardPort) : 0,
  peers: (Array.isArray(state.peers) ? state.peers : []).map(peerFactory).filter((peer) => peer.id),
});

/**
 * @method readEdgeState
 * @description The peer registry. A missing or malformed file is treated as an
 * empty registry rather than an error: the first `--wireguard-setup` on a fresh
 * host runs before any registry exists.
 * @returns {object} Normalized registry.
 * @memberof UnderpostWireguard
 */
const readEdgeState = () => {
  if (!fs.existsSync(EDGE_STATE_PATH)) return edgeStateFactory();
  try {
    return edgeStateFactory(JSON.parse(fs.readFileSync(EDGE_STATE_PATH, 'utf8')));
  } catch (error) {
    logger.warn('Ignoring unreadable wireguard registry', { target: EDGE_STATE_PATH, message: error.message });
    return edgeStateFactory();
  }
};

/**
 * @method writeEdgeState
 * @description Persists the peer registry.
 * @param {object} state - Registry to write.
 * @returns {boolean} True when the file changed.
 * @memberof UnderpostWireguard
 */
const writeEdgeState = (state) => {
  const target = EDGE_STATE_PATH;
  const next = `${JSON.stringify(edgeStateFactory(state), null, 2)}\n`;
  const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
  if (current === next) return false;
  fs.mkdirpSync(nodePath.dirname(target));
  fs.writeFileSync(target, next, 'utf8');
  return true;
};

/**
 * @method redirectHostFactory
 * @description The hostname a `redirect` points at.
 * @param {string} [redirect] - Absolute URL or bare hostname.
 * @returns {string} Hostname, or an empty string when there is none.
 * @memberof UnderpostWireguard
 */
const redirectHostFactory = (redirect) => {
  const value = `${redirect || ''}`.trim();
  if (!value) return '';
  try {
    return new URL(value.includes('://') ? value : `https://${value}`).hostname.toLowerCase();
  } catch {
    return '';
  }
};

/**
 * @method hostProxyEntriesFactory
 * @description The hostnames a `conf.server.json` publishes through the edge,
 * with the ports each one claims.
 *
 * A sub-path's `proxy` array is the declaration that the hostname is reachable
 * from outside; a host whose sub-paths declare none is internal and gets no
 * route. Ports are unioned across sub-paths because the edge routes a hostname,
 * not a sub-path — a single stream carries every path the host serves.
 * @param {object} confServer - Parsed `conf.server.json`.
 * @returns {Array<{host: string, ports: Array<number>, redirects: Array<string>}>} One entry per published hostname.
 * @memberof UnderpostWireguard
 */
const hostProxyEntriesFactory = ({ confServer = {} } = {}) => {
  const entries = [];
  for (const host of Object.keys(confServer)) {
    const paths = confServer[host] || {};
    const ports = new Set();
    const redirects = new Set();
    for (const path of Object.keys(paths)) {
      const node = paths[path] || {};
      for (const port of Array.isArray(node.proxy) ? node.proxy : [])
        if (Number.isInteger(Number(port))) ports.add(Number(port));
      const redirect = redirectHostFactory(node.redirect);
      if (redirect) redirects.add(redirect);
    }
    if (ports.size === 0) continue;
    entries.push({
      host: `${host}`.toLowerCase(),
      ports: [...ports].sort((a, b) => a - b),
      redirects: [...redirects],
    });
  }
  return entries.sort((a, b) => a.host.localeCompare(b.host));
};

/**
 * @method instanceProxyEntriesFactory
 * @description The hostnames a deploy's instances publish.
 *
 * Instances declare no `proxy` array — their hostname reaches the same cluster
 * ingress the deploy's own hosts do, so it terminates the same two ports. Every
 * variant of a family resolves through both its own id and its template id, so
 * a registry can bind a whole family with one entry.
 * @param {Array<object>} [instances] - Expanded entries from `loadConfInstances`.
 * @returns {Array<{host: string, ports: Array<number>, instances: Array<string>}>} One entry per instance hostname.
 * @memberof UnderpostWireguard
 */
const instanceProxyEntriesFactory = ({ instances = [] } = {}) => {
  const byHost = new Map();
  for (const instance of instances) {
    const host = `${instance?.host || ''}`.trim().toLowerCase();
    if (!host) continue;
    if (!byHost.has(host))
      byHost.set(host, { host, ports: [UNDERPOST_EDGE.httpPort, UNDERPOST_EDGE.httpsPort], instances: [] });
    const ids = byHost.get(host).instances;
    for (const id of [instance.id, instance.templateId].filter(Boolean)) if (!ids.includes(`${id}`)) ids.push(`${id}`);
  }
  return [...byHost.values()].sort((a, b) => a.host.localeCompare(b.host));
};

/**
 * @method edgeRouteTableFactory
 * @description Resolves every published hostname to the spoke that serves it.
 *
 * Resolution is ordered from most specific to least: an explicit hostname
 * binding, then an instance id, then the hostname a redirect points at, then the
 * default spoke. The redirect hop exists because a redirect host publishes
 * nothing of its own — `dogmadual.com` only says "go to `www.dogmadual.com`",
 * and the spoke that answers the target is the one that has to answer the
 * redirect too.
 *
 * A hostname that resolves to nothing is returned in `unresolved` rather than
 * dropped: a silently missing route is a hostname that answers nothing at all,
 * which is invisible until someone reports the outage.
 * @param {object} [confServer] - Parsed `conf.server.json`.
 * @param {Array<object>} [instances] - Expanded instance entries.
 * @param {Array<object>} [peers] - Registry entries.
 * @returns {{routes: Array<object>, unresolved: Array<string>, peers: Array<object>}} Route table, unbound hostnames, and the peers actually referenced.
 * @memberof UnderpostWireguard
 */
const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } = {}) => {
  const entries = new Map();
  for (const entry of hostProxyEntriesFactory({ confServer })) entries.set(entry.host, { ...entry, instances: [] });
  for (const entry of instanceProxyEntriesFactory({ instances })) {
    const current = entries.get(entry.host);
    if (!current) entries.set(entry.host, { ...entry, redirects: [] });
    else {
      current.ports = [...new Set([...current.ports, ...entry.ports])].sort((a, b) => a - b);
      current.instances = [...new Set([...current.instances, ...entry.instances])];
    }
  }

  const list = peers.map(peerFactory).filter((peer) => peer.id && peer.address);
  const byHost = new Map();
  const byInstance = new Map();
  // First declaration wins, so a duplicated binding is deterministic rather
  // than dependent on registry order changing under an edit.
  for (const peer of list) {
    for (const host of peer.hosts) if (!byHost.has(host)) byHost.set(host, peer);
    for (const id of peer.instances) if (!byInstance.has(id)) byInstance.set(id, peer);
  }
  const fallback = defaultPeerFactory(list);

  const resolve = (entry, seen) => {
    if (byHost.has(entry.host)) return { peer: byHost.get(entry.host), via: 'host' };
    for (const id of entry.instances) if (byInstance.has(id)) return { peer: byInstance.get(id), via: 'instance' };
    for (const target of entry.redirects) {
      if (seen.has(target)) continue;
      const targetEntry = entries.get(target);
      if (!targetEntry) continue;
      const resolved = resolve(targetEntry, new Set([...seen, entry.host]));
      if (resolved.peer && resolved.via !== 'default') return { peer: resolved.peer, via: 'redirect' };
    }
    return fallback ? { peer: fallback, via: 'default' } : { peer: null, via: '' };
  };

  const routes = [];
  const unresolved = [];
  for (const entry of [...entries.values()].sort((a, b) => a.host.localeCompare(b.host))) {
    const { peer, via } = resolve(entry, new Set([entry.host]));
    if (!peer) {
      unresolved.push(entry.host);
      continue;
    }
    routes.push({ host: entry.host, ports: entry.ports, peerId: peer.id, address: peer.address, via });
  }
  const referenced = new Set(routes.map((route) => route.peerId));
  return { routes, unresolved, peers: list.filter((peer) => referenced.has(peer.id) || peer === fallback) };
};

/**
 * @method allowedIpsConflictsFactory
 * @description CIDRs that more than one peer claims.
 *
 * WireGuard resolves an outbound packet to a peer by longest-prefix match over
 * every peer's `AllowedIPs`, so a CIDR claimed twice is not shared — one peer
 * wins and the other silently never receives that traffic. Homelabs routinely
 * sit on the same `192.168.1.0/24`, which makes this the likeliest way a
 * multi-spoke registry breaks.
 *
 * Duplicate tunnel addresses are caught by the same pass, because an address
 * with no explicit `allowedIPs` contributes its own `/32`.
 * @param {Array<object>} [peers] - Registry entries.
 * @returns {Array<{cidr: string, peers: Array<string>}>} Contested CIDRs.
 * @memberof UnderpostWireguard
 */
const allowedIpsConflictsFactory = ({ peers = [] } = {}) => {
  const byCidr = new Map();
  for (const peer of peers.map(peerFactory))
    for (const cidr of peer.allowedIPs) byCidr.set(cidr, [...new Set([...(byCidr.get(cidr) || []), peer.id])]);
  return [...byCidr.entries()]
    .filter(([, ids]) => ids.length > 1)
    .map(([cidr, ids]) => ({ cidr, peers: ids.sort() }))
    .sort((a, b) => a.cidr.localeCompare(b.cidr));
};

/**
 * @method mergeRouteTablesFactory
 * @description Folds one route table per deploy into the single table the edge
 * publishes.
 *
 * The edge is one machine holding one pair of map files, so every deploy in
 * `dd.router` has to be compiled together — publishing one deploy's table alone
 * would overwrite the maps and take every other deploy's hostname off the
 * internet. Each route records the deploy that contributed it, which is the
 * only way to attribute a hostname once the tables are merged.
 *
 * A hostname two deploys both claim is a configuration error, not a merge to
 * resolve: one of them is not being served. First wins so the output is
 * deterministic, and the collision is reported.
 * @param {Array<{deployId: string, routes: Array<object>, unresolved: Array<string>, peers: Array<object>}>} [tables] - Per-deploy tables.
 * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>}} Merged table.
 * @memberof UnderpostWireguard
 */
const mergeRouteTablesFactory = ({ tables = [] } = {}) => {
  const routes = new Map();
  const conflicts = [];
  const unresolved = [];
  const peers = new Map();
  for (const table of tables) {
    for (const route of table.routes || []) {
      const current = routes.get(route.host);
      if (!current) {
        routes.set(route.host, { ...route, deployId: table.deployId });
        continue;
      }
      if (current.peerId !== route.peerId)
        conflicts.push({ host: route.host, claimed: [current.deployId, table.deployId], serving: current.peerId });
    }
    for (const host of table.unresolved || []) unresolved.push({ host, deployId: table.deployId });
    for (const peer of table.peers || []) if (!peers.has(peer.id)) peers.set(peer.id, peer);
  }
  return {
    routes: [...routes.values()].sort((a, b) => a.host.localeCompare(b.host)),
    unresolved: unresolved.sort((a, b) => a.host.localeCompare(b.host)),
    conflicts,
    peers: [...peers.values()].sort((a, b) => a.id.localeCompare(b.id)),
  };
};

/**
 * @method backendNameFactory
 * @description A peer id as an HAProxy proxy name. Only word characters are
 * safe in one, and the prefix keeps the two transports of a single spoke
 * distinguishable in `haproxy -c` output and the stats socket.
 * @param {string} kind - `http` or `tls`.
 * @param {string} peerId - Registry peer id.
 * @returns {string} Backend name.
 * @memberof UnderpostWireguard
 */
const backendNameFactory = (kind, peerId) => `be_${kind}_${`${peerId || 'default'}`.replace(/[^a-zA-Z0-9]/g, '_')}`;

/**
 * @method haproxyMapsFactory
 * @description The two lookup tables the frontends select a backend with.
 *
 * Split by transport because the two are matched on different things — a
 * plaintext `Host` header and a TLS SNI extension — and a hostname may publish
 * one port without the other.
 * @param {Array<object>} [routes] - Route table from {@link UnderpostWireguard.edgeRouteTableFactory}.
 * @returns {{sni: string, http: string}} Rendered map files.
 * @memberof UnderpostWireguard
 */
const haproxyMapsFactory = ({ routes = [] } = {}) => {
  const render = (port, kind) =>
    routes
      .filter((route) => route.peerId && route.ports.includes(port))
      .map((route) => `${route.host} ${backendNameFactory(kind, route.peerId)}`)
      .join('\n');
  const sni = render(UNDERPOST_EDGE.httpsPort, 'tls');
  const http = render(UNDERPOST_EDGE.httpPort, 'http');
  return { sni: sni ? `${sni}\n` : '', http: http ? `${http}\n` : '' };
};

/**
 * @method haproxyConfFactory
 * @description Renders the public edge gateway.
 *
 * There is no `bind ... ssl` line anywhere in the output, and that absence is
 * the design: the `:443` frontend runs in TCP mode and selects a backend from
 * `req.ssl_sni`, which reads the ClientHello without decrypting it. The edge
 * therefore holds no certificate and no private key for any hostname it routes.
 *
 * An unmatched hostname reaches the default spoke, or — with no default
 * declared — is refused rather than dispatched somewhere arbitrary. `421` is
 * the accurate answer for cleartext: the request reached a server that does not
 * serve that authority.
 * `sshForwardPort` adds the one frontend that is not hostname-routed. SSH
 * carries no SNI and no `Host`, so there is nothing to route on: the port goes
 * whole to the fallback spoke, exactly as UDP/443 does. It exists because a
 * spoke behind CGNAT is otherwise unreachable for CI — the deploy runs on the
 * cluster, and the only public address in the topology is the hub's.
 * @param {Array<object>} [peers] - Peers to emit backends for.
 * @param {string} [defaultPeerId] - Peer an unmatched hostname falls back to.
 * @param {string} [mapDir] - Directory holding the map files.
 * @param {number} [sshForwardPort] - Public port forwarded to the spoke's SSH; 0 emits no frontend.
 * @returns {string} haproxy.cfg contents.
 * @memberof UnderpostWireguard
 */
const haproxyConfFactory = ({
  peers = [],
  defaultPeerId = '',
  mapDir = UNDERPOST_EDGE.haproxyDir,
  sshForwardPort = 0,
} = {}) => {
  const sniMap = `${mapDir}/${UNDERPOST_EDGE.sniMapName}`;
  const httpMap = `${mapDir}/${UNDERPOST_EDGE.httpMapName}`;
  const fallback = peers.find((peer) => peer.id === defaultPeerId) || defaultPeerFactory(peers);
  const backends = peers
    .map(
      (peer) => `backend ${backendNameFactory('http', peer.id)}
  mode http
  server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpPort} check

backend ${backendNameFactory('tls', peer.id)}
  mode tcp
  server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpsPort} check`,
    )
    .join('\n\n');
  const defaults = fallback
    ? `backend ${backendNameFactory('http', 'default')}
  mode http
  server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpPort} check

backend ${backendNameFactory('tls', 'default')}
  mode tcp
  server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpsPort} check`
    : `backend ${backendNameFactory('http', 'default')}
  mode http
  http-request deny deny_status 421

backend ${backendNameFactory('tls', 'default')}
  mode tcp
  tcp-request content reject`;

  // Both conditions matter: no port means SSH was never opened, and no fallback
  // means there is no spoke to send it to. Emitting a frontend without a
  // backend would bind the port and refuse every connection on it.
  const sshEnabled = Number(sshForwardPort) > 0 && Boolean(fallback);
  const sshFrontend = sshEnabled
    ? `
frontend fe_ssh
  bind :${sshForwardPort}
  mode tcp
  option tcplog
  # No inspect-delay: the server speaks first in the SSH protocol, so waiting
  # for client bytes here would stall every connection until the timeout.
  default_backend ${backendNameFactory('ssh', 'default')}
`
    : '';
  const sshBackend = sshEnabled
    ? `

backend ${backendNameFactory('ssh', 'default')}
  mode tcp
  server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.sshPort} check`
    : '';

  return `# Generated by \`underpost wireguard --haproxy-sync\`. Do not edit by hand:
# every rule here is derived from conf.server.json, conf.instances.json and
# conf.wireguard.json, and the next sync overwrites the file.
global
  log /dev/log local0
  maxconn 20000
  user haproxy
  group haproxy
  daemon
  # \`expose-fd listeners\` is what makes a reload seamless: the incoming
  # process inherits the listening sockets from the outgoing one, so no
  # connection is refused while routes change.
  stats socket ${UNDERPOST_EDGE.statsSocket} mode 660 level admin expose-fd listeners
  stats timeout 30s

defaults
  log global
  option dontlognull
  timeout connect 5s
  timeout client 60s
  timeout server 60s
  # Websockets and other long-lived streams are held by the tunnel timeout,
  # not the idle ones, or the edge would cut a healthy connection at 60s.
  timeout tunnel 1h

frontend fe_http
  bind :${UNDERPOST_EDGE.httpPort}
  mode http
  option httplog
  option forwardfor
  # Plaintext, so the Host header is readable without terminating anything.
  # The port is stripped before lookup because a client may send \`host:80\`.
  use_backend %[req.hdr(host),lower,word(1,:),map(${httpMap},${backendNameFactory('http', 'default')})]
  default_backend ${backendNameFactory('http', 'default')}

frontend fe_https
  bind :${UNDERPOST_EDGE.httpsPort}
  mode tcp
  option tcplog
  # Wait for the ClientHello, read the SNI, forward the bytes untouched. A
  # connection that never sends one falls to the default backend.
  tcp-request inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }
  use_backend %[req.ssl_sni,lower,map(${sniMap},${backendNameFactory('tls', 'default')})]
  default_backend ${backendNameFactory('tls', 'default')}
${sshFrontend}
${backends}${backends ? '\n\n' : ''}${defaults}${sshBackend}
`;
};

/**
 * @method wireguardPrivateKeyDirective
 * @description Loads the interface key from disk after the interface is up.
 *
 * The key is deliberately not inlined into the rendered config: keeping it in
 * its own 0600 file means no rendered configuration, dry-run print, diff or log
 * line can ever carry it, and the config itself becomes a pure function of the
 * registry. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
 * keyed before it forwards anything.
 * @param {string} keyPath - Path of the private key file.
 * @returns {string} `PostUp` directive.
 * @memberof UnderpostWireguard
 */
const wireguardPrivateKeyDirective = (keyPath) => `PostUp = wg set %i private-key ${keyPath}`;

/**
 * @method wireguardClientForwardingDirectivesFactory
 * @description Lets workloads behind a spoke reach tunnel services using the
 * spoke's WireGuard address. The destination-scoped masquerade avoids exposing
 * Kubernetes pod CIDRs to the hub or changing ordinary pod egress.
 * @param {string} cidr - Tunnel CIDR reachable through the hub.
 * @returns {string} Paired `wg-quick` lifecycle directives.
 * @memberof UnderpostWireguard
 */
const wireguardClientForwardingDirectivesFactory = (cidr) => {
  const outbound = `FORWARD -o %i -d ${cidr} -j ACCEPT`;
  const inbound = `FORWARD -i %i -s ${cidr} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT`;
  const masquerade = `POSTROUTING -o %i -d ${cidr} -j MASQUERADE`;
  return [
    'PostUp = sysctl -q -w net.ipv4.ip_forward=1',
    `PostUp = iptables -C ${outbound} 2>/dev/null || iptables -I ${outbound}`,
    `PostUp = iptables -C ${inbound} 2>/dev/null || iptables -I ${inbound}`,
    `PostUp = iptables -t nat -C ${masquerade} 2>/dev/null || iptables -t nat -I ${masquerade}`,
    `PostDown = iptables -t nat -D ${masquerade} 2>/dev/null || true`,
    `PostDown = iptables -D ${inbound} 2>/dev/null || true`,
    `PostDown = iptables -D ${outbound} 2>/dev/null || true`,
  ].join('\n');
};

/**
 * @method wireguardClientSettingsFactory
 * @description Reads the non-secret settings needed to reconcile an installed
 * spoke when its shared registry has no machine-local client fields.
 * @param {string} [conf] - Selected WireGuard configuration directives.
 * @returns {{address: string, hubPublicKey: string, endpoint: string, cidr: string}}
 * @memberof UnderpostWireguard
 */
const wireguardClientSettingsFactory = (conf = '') => {
  const values = new Map();
  for (const line of `${conf}`.split('\n')) {
    const match = line.match(/^\s*(Address|PublicKey|Endpoint|AllowedIPs)\s*=\s*(.*?)\s*$/i);
    if (match && !values.has(match[1].toLowerCase())) values.set(match[1].toLowerCase(), match[2]);
  }
  return {
    address: `${values.get('address') || ''}`.split(',')[0].trim(),
    hubPublicKey: `${values.get('publickey') || ''}`.trim(),
    endpoint: `${values.get('endpoint') || ''}`.trim(),
    cidr: `${values.get('allowedips') || ''}`.split(',')[0].trim(),
  };
};

/**
 * @method wireguardServerConfFactory
 * @description Renders the hub interface and its spoke table.
 *
 * Forwarding rules are attached to the interface rather than applied globally,
 * so tearing the tunnel down removes them again and a stopped hub leaves no
 * rule behind that outlives it.
 * @param {string} [interfaceName] - Interface name.
 * @param {string} address - Hub address with CIDR (`10.0.0.1/24`).
 * @param {number} [listenPort] - UDP listen port.
 * @param {string} keyPath - Path of the private key file.
 * @param {Array<object>} [peers] - Registry entries.
 * @returns {string} `wg-quick` configuration.
 * @memberof UnderpostWireguard
 */
const wireguardServerConfFactory = ({
  interfaceName = UNDERPOST_EDGE.interfaceName,
  address,
  listenPort = UNDERPOST_EDGE.listenPort,
  keyPath,
  peers = [],
} = {}) => {
  const peerBlocks = peers
    .map(peerFactory)
    .filter((peer) => peer.id && peer.publicKey && peer.allowedIPs.length > 0)
    .map(
      (peer) => `
[Peer]
# ${peer.id}
PublicKey = ${peer.publicKey}
AllowedIPs = ${peer.allowedIPs.join(', ')}`,
    )
    .join('\n');
  return `# Generated by \`underpost wireguard --wireguard-setup --server\`. Do not edit by hand.
[Interface]
Address = ${address}
ListenPort = ${listenPort}
${wireguardPrivateKeyDirective(keyPath)}
PostUp = sysctl -q -w net.ipv4.ip_forward=1
PostUp = iptables -I FORWARD -i ${interfaceName} -j ACCEPT
PostUp = iptables -I FORWARD -o ${interfaceName} -j ACCEPT
PostDown = iptables -D FORWARD -i ${interfaceName} -j ACCEPT
PostDown = iptables -D FORWARD -o ${interfaceName} -j ACCEPT
${peerBlocks}
`;
};

/**
 * @method wireguardClientConfFactory
 * @description Renders a spoke interface.
 *
 * `AllowedIPs` is the tunnel CIDR alone, never `0.0.0.0/0`: the spoke is
 * publishing services through the hub, not routing its own egress through it,
 * and a default route here would send a whole cluster's outbound traffic across
 * the VPS.
 * @param {string} address - Spoke address (`10.0.0.2`, with or without CIDR).
 * @param {string} keyPath - Path of the private key file.
 * @param {string} publicKey - Hub public key.
 * @param {string} endpoint - Hub `host:port`.
 * @param {string} [cidr] - Tunnel CIDR reachable through the hub.
 * @param {number} [keepalive] - Keepalive interval in seconds.
 * @returns {string} `wg-quick` configuration.
 * @memberof UnderpostWireguard
 */
const wireguardClientConfFactory = ({
  address,
  keyPath,
  publicKey,
  endpoint,
  cidr = UNDERPOST_EDGE.tunnelCidr,
  keepalive = UNDERPOST_EDGE.keepalive,
} = {}) => `# Generated by \`underpost wireguard --wireguard-setup --client\`. Do not edit by hand.
[Interface]
Address = ${`${address}`.includes('/') ? address : `${address}/32`}
${wireguardPrivateKeyDirective(keyPath)}
${wireguardClientForwardingDirectivesFactory(cidr)}

[Peer]
PublicKey = ${publicKey}
Endpoint = ${endpoint}
AllowedIPs = ${cidr}
# Holds the outbound NAT mapping open, which is the only reason a hub behind no
# NAT can reach a spoke behind CGNAT at all.
PersistentKeepalive = ${keepalive}
`;

/**
 * @method quicForwardCommandsFactory
 * @description The packet rules that carry QUIC to the default spoke, and the
 * ones that remove them again.
 *
 * QUIC cannot be routed by hostname — a QUIC Initial carries its SNI inside an
 * encrypted frame — so the whole UDP port goes to one spoke, exactly as
 * `underpost-ingress` sends every datagram to one data plane. A client that
 * tries QUIC against another spoke's hostname gets no answer and falls back to
 * TCP, which is routed correctly.
 *
 * The rules live in dedicated chains that are flushed and refilled on every
 * apply, which is what makes a re-run idempotent: there is no accumulating list
 * of near-duplicate rules and no need to guess what a previous run installed.
 * @param {string} [chainPrefix] - Prefix for the two managed chains.
 * @param {string} [interfaceName] - Tunnel interface.
 * @param {string} [target] - Spoke address; empty renders the chains without rules.
 * @param {number} [port] - UDP port to forward.
 * @returns {{ensure: Array<string>, remove: Array<string>}} Shell commands.
 * @memberof UnderpostWireguard
 */
const quicForwardCommandsFactory = ({
  chainPrefix = UNDERPOST_EDGE.natChainPrefix,
  interfaceName = UNDERPOST_EDGE.interfaceName,
  target = '',
  port = UNDERPOST_EDGE.httpsPort,
} = {}) => {
  const pre = `${chainPrefix}_PRE`;
  const post = `${chainPrefix}_POST`;
  return {
    ensure: [
      `sudo iptables -t nat -N ${pre} 2>/dev/null || true`,
      `sudo iptables -t nat -N ${post} 2>/dev/null || true`,
      `sudo iptables -t nat -C PREROUTING -j ${pre} 2>/dev/null || sudo iptables -t nat -A PREROUTING -j ${pre}`,
      `sudo iptables -t nat -C POSTROUTING -j ${post} 2>/dev/null || sudo iptables -t nat -A POSTROUTING -j ${post}`,
      `sudo iptables -t nat -F ${pre}`,
      `sudo iptables -t nat -F ${post}`,
      ...(target
        ? [
            // `! -i <iface>` keeps datagrams that already arrived through the
            // tunnel from being re-DNAT'd back into it.
            `sudo iptables -t nat -A ${pre} ! -i ${interfaceName} -p udp --dport ${port} -j DNAT --to-destination ${target}:${port}`,
            `sudo iptables -t nat -A ${post} -o ${interfaceName} -d ${target} -p udp --dport ${port} -j MASQUERADE`,
          ]
        : []),
    ],
    remove: [
      `sudo iptables -t nat -D PREROUTING -j ${pre} 2>/dev/null || true`,
      `sudo iptables -t nat -D POSTROUTING -j ${post} 2>/dev/null || true`,
      `sudo iptables -t nat -F ${pre} 2>/dev/null || true`,
      `sudo iptables -t nat -F ${post} 2>/dev/null || true`,
      `sudo iptables -t nat -X ${pre} 2>/dev/null || true`,
      `sudo iptables -t nat -X ${post} 2>/dev/null || true`,
    ],
  };
};

/**
 * @method firewallCommandsFactory
 * @description Opens the ports each role needs, when firewalld is running.
 *
 * A spoke opens nothing publicly — it dials out — but its tunnel interface has
 * to land in a zone that permits forwarded traffic, or the cluster ingress is
 * unreachable over a tunnel that is otherwise perfectly healthy.
 * The same list drives the teardown, flipped to `--remove-*`, so a reset cannot
 * leave behind a permanent rule the setup added — the two directions are one
 * declaration rather than two that drift.
 * @param {string} role - `server` or `client`.
 * @param {string} [interfaceName] - Tunnel interface.
 * @param {number} [listenPort] - UDP listen port.
 * @param {string} [tunnelCidr] - Tunnel source admitted to the forward proxy.
 * @param {number} [sshForwardPort] - Public SSH port to open; 0 opens none.
 * @param {boolean} [remove] - Withdraw the rules instead of adding them.
 * @returns {Array<string>} Shell commands, each a no-op when firewalld is absent.
 * @memberof UnderpostWireguard
 */
const firewallCommandsFactory = ({
  role,
  interfaceName = UNDERPOST_EDGE.interfaceName,
  listenPort = UNDERPOST_EDGE.listenPort,
  tunnelCidr = UNDERPOST_EDGE.tunnelCidr,
  sshForwardPort = 0,
  remove = false,
} = {}) => {
  const verb = remove ? 'remove' : 'add';
  const rules =
    role === 'server'
      ? [
          `--${verb}-port=${UNDERPOST_EDGE.httpPort}/tcp`,
          `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/tcp`,
          `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/udp`,
          `--${verb}-port=${listenPort}/udp`,
          // Public, unlike the forward proxy: CI dials it from GitHub's runners,
          // which have no fixed address to narrow the rule to.
          ...(Number(sshForwardPort) > 0 ? [`--${verb}-port=${sshForwardPort}/tcp`] : []),
          // The forward proxy is admitted from the tunnel only. The listener
          // already binds the tunnel address alone, so this rule narrows a port
          // that is unreachable from anywhere else rather than opening one.
          `--${verb}-rich-rule="rule family=ipv4 source address=${tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
          `--${verb}-masquerade`,
        ]
      : [`--zone=trusted --${verb}-interface=${interfaceName}`];
  const guard =
    'command -v firewall-cmd >/dev/null 2>&1 && ' +
    systemctlCommandFactory({ action: 'is-active --quiet', name: 'firewalld', sudo: false });
  return [
    ...rules.map((rule) => `sudo sh -c '${guard} && firewall-cmd --permanent ${rule} >/dev/null || true'`),
    `sudo sh -c '${guard} && firewall-cmd --reload >/dev/null || true'`,
  ];
};

/**
 * @method peerSummaryFactory
 * @description One spoke as the registry declares it: its transport address and
 * the three bindings that route hostnames to it.
 * @param {object} peer - Normalized registry entry.
 * @returns {object} Registry view of the peer.
 * @memberof UnderpostWireguard
 */
const peerSummaryFactory = (peer) => ({
  id: peer.id,
  address: peer.address,
  allowedIPs: peer.allowedIPs,
  hosts: peer.hosts,
  instances: peer.instances,
  default: peer.default,
});

/**
 * @method wireguardStatusFactory
 * @description Folds `wg show` output onto the registry, one row per spoke.
 *
 * Built from the per-peer sub-commands rather than `wg show <iface> dump`,
 * because the dump's first line contains the interface private key — this
 * command prints its output, so it must never read that line at all.
 * @param {Array<object>} [peers] - Registry entries.
 * @param {string} [latestHandshakes] - `wg show <iface> latest-handshakes` output.
 * @param {string} [transfer] - `wg show <iface> transfer` output.
 * @param {string} [endpoints] - `wg show <iface> endpoints` output.
 * @param {number} [now] - Current time in epoch seconds.
 * @returns {Array<object>} One row per peer.
 * @memberof UnderpostWireguard
 */
const wireguardStatusFactory = ({
  peers = [],
  latestHandshakes = '',
  transfer = '',
  endpoints = '',
  now = Math.floor(Date.now() / 1000),
} = {}) => {
  const parse = (raw) =>
    new Map(
      `${raw || ''}`
        .split('\n')
        .map((line) => line.trim())
        .filter(Boolean)
        .map((line) => {
          const [key, ...rest] = line.split(/\s+/);
          return [key, rest];
        }),
    );
  const handshakes = parse(latestHandshakes);
  const transfers = parse(transfer);
  const peerEndpoints = parse(endpoints);
  return peers.map(peerFactory).map((peer) => {
    const handshake = Number(handshakes.get(peer.publicKey)?.[0] || 0);
    const [rx = '0', tx = '0'] = transfers.get(peer.publicKey) || [];
    const endpoint = peerEndpoints.get(peer.publicKey)?.[0] || '';
    return {
      ...peerSummaryFactory(peer),
      endpoint: endpoint && endpoint !== '(none)' ? endpoint : '',
      handshakeAgeSeconds: handshake > 0 ? now - handshake : null,
      rxBytes: Number(rx) || 0,
      txBytes: Number(tx) || 0,
      online: handshake > 0 && now - handshake <= UNDERPOST_EDGE.handshakeStaleSeconds,
    };
  });
};

/**
 * @method writeRootFile
 * @description Places a file the host's services read, at an explicit mode.
 *
 * Staged and installed rather than written directly: the deploy may run
 * unprivileged while `/etc/wireguard` and `/etc/haproxy` are root-owned, and
 * `install -m` sets the mode as it copies, so a 0600 file is never briefly
 * readable at the default umask.
 * @param {string} target - Destination path.
 * @param {string} content - File contents.
 * @param {string} [mode] - Octal mode.
 * @param {boolean} [dryRun] - Print the destination instead of writing.
 * @returns {boolean} True when the file changed.
 * @memberof UnderpostWireguard
 */
const writeRootFile = ({ target, content, mode = '0644', dryRun = false }) => {
  const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
  if (current === content) return false;
  if (dryRun) {
    logger.info(`[dry-run] write ${target} (mode ${mode})`, { bytes: content.length });
    return true;
  }
  const staged = nodePath.join('/tmp', `underpost-edge-${nodePath.basename(target)}-${process.pid}`);
  fs.writeFileSync(staged, content, { mode: 0o600 });
  shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
  shellExec(`sudo install -m ${mode} -o root -g root ${staged} ${target}`, { silent: true });
  fs.removeSync(staged);
  return true;
};

/**
 * @method writeServerInterfaceConf
 * @description Rewrites the hub interface config from the current peer table.
 *
 * A spoke's config holds no peer table of its own, so this is a no-op there —
 * which is what lets `--peer-add` and `--peer-remove` run unconditionally.
 * @param {object} state - Registry the interface belongs to.
 * @param {Array<object>} peers - Peer table to render.
 * @param {boolean} [dryRun] - Print instead of writing.
 * @returns {boolean} True when the file changed.
 * @memberof UnderpostWireguard
 */
const writeServerInterfaceConf = ({ state, peers, dryRun = false }) => {
  if (state.role !== 'server') return false;
  return writeRootFile({
    target: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.conf`,
    content: wireguardServerConfFactory({
      interfaceName: state.interfaceName,
      address: state.address,
      listenPort: state.listenPort,
      keyPath: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.key`,
      peers,
    }),
    mode: '0600',
    dryRun,
  });
};

/**
 * @method runHostCommands
 * @description Executes a host-mutating command list, or prints it.
 * @param {Array<string>} commands - Shell commands.
 * @param {boolean} [dryRun] - Print instead of executing.
 * @returns {void}
 * @memberof UnderpostWireguard
 */
const runHostCommands = (commands, dryRun = false) => {
  for (const command of commands) {
    if (dryRun) logger.info(`[dry-run] ${command}`);
    else shellExec(command, { silent: true });
  }
};

const runServiceCommands = (commands, dryRun = false) =>
  runSystemdCommands(commands, {
    dryRun,
    execute: (command) => shellExec(command, { silent: true }),
    onDryRun: (command) => logger.info(`[dry-run] ${command}`),
  });

/**
 * @method csvFactory
 * @description Splits a comma-separated CLI value, or reports that the flag was
 * not passed at all — which is what lets a partial update leave the stored
 * fields it does not name untouched.
 * @param {string} [value] - Raw option value.
 * @returns {?Array<string>} Trimmed entries, or `undefined` when absent.
 * @memberof UnderpostWireguard
 */
const csvFactory = (value) =>
  value === undefined || value === null || `${value}`.trim() === ''
    ? undefined
    : `${value}`
        .split(',')
        .map((entry) => entry.trim())
        .filter(Boolean);

/**
 * @method liveWireguardCommand
 * @description Applies a `wg set` change to a running interface, and does
 * nothing when there is none.
 *
 * `wg set` is what admits or drops a peer without restarting the interface, so
 * established tunnels keep their sessions. It is also the only step that
 * requires the interface to be up — persistence is the regenerated config file,
 * so a host whose tunnel is currently down still records the change.
 * @param {string} interfaceName - Interface name.
 * @param {string} args - Arguments after `wg set <interface>`.
 * @returns {string} Shell command.
 * @memberof UnderpostWireguard
 */
const liveWireguardCommand = (interfaceName, args) =>
  `sudo sh -c 'wg show ${interfaceName} >/dev/null 2>&1 && wg set ${interfaceName} ${args} || true'`;

/**
 * @method warnRegistryHazards
 * @description Reports the two registry states that break routing silently.
 *
 * A peer with no `hosts` and no `instances` claims nothing. It still serves
 * every hostname while it is the *only* peer, because a lone peer is its own
 * fallback — so a single-spoke edge works with no bindings at all. Adding a
 * second peer removes that implicit fallback, and every hostname the bindings do
 * not name goes unresolved at once, along with the QUIC forward.
 *
 * That cliff arrives when the peer is registered, not when routes are published,
 * so it is reported at both points rather than left for the next sync to
 * discover.
 * @param {Array<object>} [peers] - The registry's peers.
 * @returns {void}
 * @memberof UnderpostWireguard
 */
const warnRegistryHazards = (peers = []) => {
  if (peers.length === 0) {
    logger.warn('Registry has no peers: no hostname can resolve until at least one is registered', {
      next: '--peer-add <id> --peer-ip <10.0.0.x> --public-key <key>',
    });
    return;
  }
  if (peers.length > 1 && !peers.some((peer) => peer.default))
    logger.warn('No peer is marked --default: unmatched hostnames are unresolved and UDP :443 is not forwarded', {
      peers: peers.map((peer) => peer.id),
      fix: '--peer-add <id> --peer-ip <ip> --public-key <key> --default',
    });
  const conflicts = allowedIpsConflictsFactory({ peers });
  if (conflicts.length > 0)
    logger.warn('Overlapping AllowedIPs: only one peer can receive traffic for each contested CIDR', {
      conflicts,
      fix: 'give each spoke a distinct tunnel address, and re-number or omit colliding LAN subnets',
    });
};

/** Return an interface address without its CIDR prefix. */
const tunnelAddressFactory = (address) => `${address || ''}`.trim().split('/')[0];

const tunnelNetworkCidrFactory = (address, fallback = UNDERPOST_EDGE.tunnelCidr) => {
  const [host, prefixValue] = `${address || ''}`.trim().split('/');
  const octets = host.split('.').map(Number);
  const prefix = Number(prefixValue);
  if (
    octets.length !== 4 ||
    octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) ||
    !Number.isInteger(prefix) ||
    prefix < 0 ||
    prefix > 32
  )
    return fallback;
  const value = octets.reduce((result, octet) => ((result << 8) | octet) >>> 0, 0);
  const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
  const network = (value & mask) >>> 0;
  return `${[network >>> 24, (network >>> 16) & 255, (network >>> 8) & 255, network & 255].join('.')}/${prefix}`;
};

/**
 * @class UnderpostWireguard
 * @description Lifecycle for the edge: the WireGuard transport that reaches the
 * spokes, and the HAProxy gateway that selects one per connection.
 * @memberof UnderpostWireguard
 */
class UnderpostWireguard {
  static API = {
    /**
     * @method install
     * @description Installs the host packages the subsystem needs.
     *
     * Each package is queried before anything is installed, so a host that is
     * already provisioned performs no work and a partially provisioned one
     * installs only what it is missing.
     * @param {object} [options] - CLI options.
     * @returns {Array<string>} Packages that were missing.
     * @memberof UnderpostWireguard
     */
    install(options = {}) {
      if (shellExec('command -v dnf', { stdout: true, silent: true, silentOnError: true }).trim() === '')
        throw new Error('[wireguard] No dnf on this host; the edge subsystem targets RHEL 9 / Rocky Linux 9');
      const missing = UNDERPOST_EDGE.packages.filter(
        (name) => shellExec(`rpm -q ${name}`, { silent: true, silentOnError: true }).code !== 0,
      );
      if (missing.length === 0) {
        logger.info('Edge packages already installed', { packages: UNDERPOST_EDGE.packages });
        return [];
      }
      runHostCommands(
        [
          `sudo dnf -y install epel-release || true`,
          `sudo dnf -y install ${missing.join(' ')}`,
          // HAProxy dials backends on the tunnel; without this boolean SELinux
          // refuses the connection and every route answers 503.
          `sudo sh -c 'command -v setsebool >/dev/null 2>&1 && setsebool -P haproxy_connect_any 1 || true'`,
        ],
        options.dryRun,
      );
      logger.info('Edge packages installed', { missing });
      return missing;
    },

    /**
     * @method ensureKeyPair
     * @description Resolves the interface key pair, generating it once.
     *
     * The private half is created under `umask 077` inside the same shell that
     * writes it, so it is never readable by anyone but root — not even for the
     * moment between creation and a later `chmod`. It is never read back into
     * this process; only the public half is returned.
     * @param {string} interfaceName - Interface name.
     * @param {boolean} [dryRun] - Skip generation.
     * @returns {{privateKeyPath: string, publicKeyPath: string, publicKey: string, generated: boolean}} Key pair locations and the public key.
     * @memberof UnderpostWireguard
     */
    ensureKeyPair(interfaceName, dryRun = false) {
      const privateKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`;
      const publicKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`;
      const exists =
        shellExec(`sudo test -s ${privateKeyPath} && sudo test -s ${publicKeyPath}`, {
          silent: true,
          silentOnError: true,
        }).code === 0;
      if (!exists) {
        if (dryRun) {
          logger.info(`[dry-run] generate wireguard key pair for ${interfaceName}`);
          return { privateKeyPath, publicKeyPath, publicKey: '', generated: true };
        }
        shellExec(
          `sudo sh -c 'umask 077 && mkdir -p ${UNDERPOST_EDGE.wireguardDir} && wg genkey > ${privateKeyPath} && wg pubkey < ${privateKeyPath} > ${publicKeyPath}'`,
          { silent: true },
        );
        shellExec(`sudo chmod 0600 ${privateKeyPath} && sudo chmod 0644 ${publicKeyPath}`, { silent: true });
      }
      const publicKey = shellExec(`sudo cat ${publicKeyPath}`, {
        stdout: true,
        silent: true,
        silentOnError: true,
      }).trim();
      return { privateKeyPath, publicKeyPath, publicKey, generated: !exists };
    },

    /**
     * @method setup
     * @description Builds the tunnel interface for either role and persists what
     * the other side needs to know.
     *
     * The hub writes its own address and public key into the registry so a spoke
     * can be configured from the same file the routes are derived from; a spoke
     * records the hub it dials. Re-running with the same inputs rewrites the same
     * bytes and reloads nothing.
     * @param {object} options - CLI options.
     * @returns {object} The registry as persisted.
     * @memberof UnderpostWireguard
     */
    setup(options = {}) {
      const state = readEdgeState();
      const buildConf = options.buildConf === true;
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      if (options.server === true && options.client === true)
        throw new Error('[wireguard] --server and --client are mutually exclusive');
      const installedClient =
        buildConf || options.server === true
          ? wireguardClientSettingsFactory()
          : wireguardClientSettingsFactory(
              shellExec(
                `sudo sed -n -E '/^[[:space:]]*(Address|PublicKey|Endpoint|AllowedIPs)[[:space:]]*=/p' ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
                { stdout: true, silent: true, silentOnError: true },
              ).trim(),
            );
      // A re-run on a configured host inherits its recorded role, so repeating
      // the setup does not require repeating every flag that established it.
      const role =
        options.server === true
          ? 'server'
          : options.client === true
            ? 'client'
            : installedClient.endpoint
              ? 'client'
              : state.role;
      if (!role) throw new Error('[wireguard] --wireguard-setup requires --server or --client');
      const listenPort = Number(options.port) > 0 ? Number(options.port) : state.listenPort;
      // `--ssh-forward-port 0` is the off switch, so an explicit 0 has to win
      // over the stored value rather than read as "not supplied".
      const sshForwardPort =
        options.sshForwardPort === undefined || options.sshForwardPort === null || `${options.sshForwardPort}` === ''
          ? state.sshForwardPort
          : Math.max(0, Number(options.sshForwardPort) || 0);
      // Authoring the registry must work on a machine that is not the edge — a
      // workstation has no `/etc/wireguard` to key, and generating one there
      // would mint an identity no host will ever present.
      const { privateKeyPath, publicKey } = buildConf
        ? { privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`, publicKey: state.publicKey }
        : UnderpostWireguard.API.ensureKeyPair(interfaceName, options.dryRun);

      let conf;
      let next;
      if (role === 'server') {
        const address = `${options.cidr || state.address || UNDERPOST_EDGE.cidr}`.trim();
        if (!address.includes('/')) throw new Error('[wireguard] --cidr must carry a prefix length (e.g. 10.0.0.1/24)');
        conf = wireguardServerConfFactory({
          interfaceName,
          address,
          listenPort,
          keyPath: privateKeyPath,
          peers: state.peers,
        });
        next = { ...state, interfaceName, role, listenPort, address, publicKey, sshForwardPort };
      } else {
        const address = `${options.peerIp || installedClient.address || state.address || ''}`.trim();
        const endpoint = `${options.endpoint || installedClient.endpoint || state.endpoint || ''}`.trim();
        const hubPublicKey = `${options.publicKey || installedClient.hubPublicKey || state.hubPublicKey || ''}`.trim();
        const cidr = `${options.cidr || installedClient.cidr || UNDERPOST_EDGE.tunnelCidr}`.trim();
        if (!address) throw new Error('[wireguard] --client requires --peer-ip');
        if (!endpoint) throw new Error('[wireguard] --client requires --endpoint (e.g. vps.example.com:51820)');
        if (!hubPublicKey) throw new Error('[wireguard] --client requires --public-key (the hub public key)');
        conf = wireguardClientConfFactory({
          address,
          keyPath: privateKeyPath,
          publicKey: hubPublicKey,
          endpoint,
          cidr,
          keepalive: UNDERPOST_EDGE.keepalive,
        });
        next = { ...state, interfaceName, role, listenPort, address, endpoint, hubPublicKey, publicKey };
      }

      let confChanged = false;
      if (!buildConf) {
        confChanged = writeRootFile({
          target: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
          content: conf,
          mode: '0600',
          dryRun: options.dryRun,
        });
        runHostCommands(
          [
            `sudo sh -c 'echo net.ipv4.ip_forward=1 > ${UNDERPOST_EDGE.sysctlPath}'`,
            `sudo sysctl -q --system`,
            ...firewallCommandsFactory({
              role,
              interfaceName,
              listenPort,
              tunnelCidr: tunnelNetworkCidrFactory(next.address),
              sshForwardPort: next.sshForwardPort,
            }),
          ],
          options.dryRun,
        );
      }
      if (!options.dryRun) writeEdgeState(next);
      // Writing the config is idempotent; the *running* interface is not. wg-quick
      // reads the file only at start, so a changed config under a live unit is a
      // divergence that stays silent until the next reboot re-reads it.
      const restartRequired =
        confChanged &&
        !options.dryRun &&
        shellExec(
          systemctlCommandFactory({ action: 'is-active --quiet', name: `wg-quick@${interfaceName}`, sudo: false }),
          { silent: true, silentOnError: true },
        ).code === 0;
      logger.info(buildConf ? 'Registry updated; host untouched' : 'WireGuard interface configured', {
        interfaceName,
        role,
        address: next.address,
        publicKey: next.publicKey,
        peers: next.peers.length,
        restartRequired,
      });
      if (restartRequired)
        logger.warn('Interface config changed while the tunnel is up; the live interface still runs the old one', {
          apply: `--wireguard-stop --wireguard-start --interface ${interfaceName}`,
        });
      return next;
    },

    /**
     * @method peerAdd
     * @description Registers a spoke and applies it to the running hub.
     *
     * `wg set` installs the peer on the live interface, so an existing tunnel is
     * never interrupted to admit a new one — the registry and the config file
     * are updated in the same pass so the peer also survives a restart.
     *
     * A spoke that re-keys is registered under the same id with a new public key.
     * WireGuard identifies a peer *by its key*, not by any name, so admitting the
     * new one does not replace the old: the superseded key would stay on the live
     * interface still claiming the same `AllowedIPs`, and longest-prefix match
     * could keep handing that traffic to an identity the spoke no longer holds.
     * It is dropped first, which is what makes a reconnect leave no trace.
     * @param {object} options - CLI options.
     * @returns {object} The updated registry.
     * @memberof UnderpostWireguard
     */
    peerAdd(options = {}) {
      const state = readEdgeState();
      const id = `${options.peerAdd || ''}`.trim();
      const address = `${options.peerIp || ''}`.trim();
      const publicKey = `${options.publicKey || ''}`.trim();
      if (!id) throw new Error('[wireguard] --peer-add requires a peer id');
      if (!address) throw new Error('[wireguard] --peer-add requires --peer-ip');
      if (!publicKey) throw new Error('[wireguard] --peer-add requires --public-key');
      // Only flags that were actually passed override the stored entry, so
      // re-registering a spoke to correct its key does not silently drop the
      // subnets and bindings a previous run gave it.
      const overrides = Object.fromEntries(
        [
          ['allowedIPs', csvFactory(options.allowedIps)],
          ['hosts', csvFactory(options.hosts)],
          ['instances', csvFactory(options.instances)],
          ['default', options.default === true ? true : undefined],
        ].filter(([, value]) => value !== undefined),
      );
      const current = state.peers.find((entry) => entry.id === id) || {};
      const peer = peerFactory({ ...current, id, address, publicKey, ...overrides });
      const supersededKey = current.publicKey && current.publicKey !== publicKey ? current.publicKey : '';
      const peers = [...state.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
      const next = { ...state, peers };
      if (options.buildConf !== true) {
        runHostCommands(
          [
            ...(supersededKey ? [liveWireguardCommand(next.interfaceName, `peer ${supersededKey} remove`)] : []),
            liveWireguardCommand(next.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
          ],
          options.dryRun,
        );
        writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
      }
      if (!options.dryRun) writeEdgeState(next);
      logger.info(options.buildConf === true ? 'Peer recorded; host untouched' : 'Peer registered', {
        id,
        address,
        allowedIPs: peer.allowedIPs,
        rekeyed: supersededKey !== '',
      });
      warnRegistryHazards(peers);
      return next;
    },

    /**
     * @method peerRemove
     * @description Removes a spoke from the registry and from the running hub.
     * @param {object} options - CLI options.
     * @returns {object} The updated registry.
     * @memberof UnderpostWireguard
     */
    peerRemove(options = {}) {
      const state = readEdgeState();
      const id = `${options.peerRemove || ''}`.trim();
      const peer = state.peers.find((entry) => entry.id === id);
      if (!peer) {
        logger.warn('No such peer in registry', { id });
        return state;
      }
      const peers = state.peers.filter((entry) => entry.id !== id);
      const next = { ...state, peers };
      if (options.buildConf !== true) {
        runHostCommands([liveWireguardCommand(next.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
        writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
      }
      if (!options.dryRun) writeEdgeState(next);
      logger.info(options.buildConf === true ? 'Peer removed from registry; host untouched' : 'Peer removed', { id });
      warnRegistryHazards(peers);
      return next;
    },

    /**
     * @method buildConf
     * @description Rewrites the registry in place, normalized, touching no host.
     *
     * The registry is authored, not derived — its peer bindings and public keys
     * exist nowhere else, so nothing can regenerate it from other configuration.
     * This is the repair path: it fills in defaults a hand-edited file omitted,
     * drops entries with no id, and reports what it read, so a malformed registry
     * is corrected before a sync acts on it.
     * @param {object} [options] - CLI options.
     * @returns {object} The normalized registry.
     * @memberof UnderpostWireguard
     */
    buildConf(options = {}) {
      const state = readEdgeState();
      const changed = options.dryRun ? false : writeEdgeState(state);
      logger.info('Registry normalized', {
        target: EDGE_STATE_PATH,
        role: state.role,
        address: state.address,
        peers: state.peers.map((peer) => peer.id),
        changed,
      });
      if (!state.role)
        logger.warn('Registry records no role for this machine', {
          next: '--build-conf --wireguard-setup --server --cidr 10.0.0.1/24   (or --client …)',
        });
      warnRegistryHazards(state.peers);
      return state;
    },

    /**
     * @method routeTable
     * @description The resolved hostname-to-spoke table for one deploy, a list,
     * or every deploy in `dd.router`.
     *
     * Reads each deploy's configuration through the same helpers the cluster
     * runners use, so a hostname the edge routes is exactly a hostname the
     * cluster publishes.
     *
     * A deploy listed in `dd.router` whose configuration is not checked out
     * locally is skipped with a warning rather than failing the run: the private
     * conf of an unrelated deploy is not a precondition for publishing the ones
     * that are present.
     *
     * Omitting the id means `dd` — the whole of `dd.router`. The edge holds one
     * pair of map files for the cluster, so a complete table is the only default
     * that publishes a working edge; narrowing it is the deliberate act.
     * @param {string} [deployId] - Deploy id, comma-separated list, or `dd` (the default).
     * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>, deployList: Array<string>, missing: Array<string>}} Merged table.
     * @throws {Error} When no requested deploy has a readable configuration.
     * @memberof UnderpostWireguard
     */
    routeTable(deployId) {
      const deployList = deployListFactory(deployId || 'dd');
      if (deployList.length === 0) throw new Error('[wireguard] --deploy-id resolved to no deploys');
      const state = readEdgeState();
      const tables = [];
      const missing = [];
      for (const id of deployList) {
        const confServerPath = getConfFilePath(id, 'server');
        if (!fs.existsSync(confServerPath)) {
          missing.push(id);
          continue;
        }
        const instancesPath = `./engine-private/conf/${id}/conf.instances.json`;
        tables.push({
          deployId: id,
          ...edgeRouteTableFactory({
            confServer: loadConfServerJson(confServerPath),
            instances: fs.existsSync(instancesPath) ? loadConfInstances(id) : [],
            peers: state.peers,
          }),
        });
      }
      if (missing.length > 0) logger.warn('Deploys with no local configuration were skipped', { missing });
      if (tables.length === 0)
        throw new Error(`[wireguard] No readable deploy configuration among: ${deployList.join(', ')}`);
      return { ...mergeRouteTablesFactory({ tables }), deployList, missing };
    },

    /**
     * @method status
     * @description The whole edge context in one report: this machine's role and
     * transport, both daemons, every spoke with its bindings and link health, and
     * the routing the gateway would publish.
     *
     * This is the only read-only entry point. Host probes are skipped under
     * `--build-conf`, which has to run on a workstation with no interface to
     * query, so an off-box run reports registry and routing alone.
     * @param {object} options - CLI options.
     * @returns {object} Runtime, registry and routing summary.
     * @memberof UnderpostWireguard
     */
    status(options = {}) {
      const state = readEdgeState();
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      const probeHost = options.buildConf !== true;
      const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
      const show = (subCommand) => (probeHost ? read(`sudo wg show ${interfaceName} ${subCommand}`) : '');

      let table = null;
      try {
        table = UnderpostWireguard.API.routeTable(options.deployId);
      } catch (error) {
        logger.warn('Route table unavailable', { message: error.message });
      }

      const via = {};
      for (const route of table?.routes || []) via[route.via] = (via[route.via] || 0) + 1;
      const summary = {
        role: state.role || '(unset)',
        interface: interfaceName,
        address: state.address,
        endpoint: state.endpoint,
        publicKey: state.publicKey,
        ...(probeHost
          ? {
              wireguard: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
              haproxy: read(systemdStatusCommandsFactory('haproxy').active),
              forwardProxy: read(systemdStatusCommandsFactory(FORWARD_PROXY.serviceName).active),
              quicTarget: defaultPeerFactory(state.peers)?.address || '',
              sshForward: state.sshForwardPort
                ? `:${state.sshForwardPort} -> ${defaultPeerFactory(state.peers)?.address || '(no spoke)'}:${UNDERPOST_EDGE.sshPort}`
                : '(closed)',
            }
          : {}),
        peers: probeHost
          ? wireguardStatusFactory({
              peers: state.peers,
              latestHandshakes: show('latest-handshakes'),
              transfer: show('transfer'),
              endpoints: show('endpoints'),
            })
          : state.peers.map(peerSummaryFactory),
        routing: table
          ? {
              deployList: table.deployList,
              missing: table.missing,
              via,
              routes: table.routes.map((route) => `${route.host} -> ${route.peerId} (${route.via}, ${route.deployId})`),
            }
          : null,
      };
      logger.info('Edge status', summary);

      if (table && table.routes.length === 0)
        logger.warn('No hostname resolved to a spoke: the edge would refuse every request', {
          unresolved: table.unresolved.length,
          next: 'register a peer with --peer-add, then bind it with --hosts / --instances, or mark it --default',
        });
      else if (table?.unresolved.length > 0)
        logger.warn('Hostnames with no spoke binding', { unresolved: table.unresolved });
      if (table?.conflicts.length > 0)
        logger.warn('Hostnames claimed by more than one deploy; only the first is served', {
          conflicts: table.conflicts,
        });
      warnRegistryHazards(state.peers);
      return summary;
    },

    /**
     * @method haproxySync
     * @description Recompiles the routing tables from the deploy configuration
     * and hot-reloads HAProxy.
     *
     * The candidate config is validated before the running process is signalled,
     * and the previous files are put back if it fails — a config that does not
     * parse would otherwise take the whole edge down on reload. The reload itself
     * hands the listening sockets to the incoming process, so established
     * connections and the tunnels underneath them are untouched.
     * @param {object} options - CLI options.
     * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, changed: boolean}} What was published.
     * @throws {Error} When HAProxy rejects the candidate config.
     * @memberof UnderpostWireguard
     */
    haproxySync(options = {}) {
      const { routes, unresolved, conflicts, peers, deployList } = UnderpostWireguard.API.routeTable(options.deployId);
      // Fail before the per-hostname report: with nothing resolved, listing
      // every unbound hostname buries the one line that explains the run.
      if (routes.length === 0)
        throw new Error(
          '[wireguard] No hostname resolved to a spoke; publishing would refuse every request. ' +
            'Register a peer with --peer-add and bind it with --hosts / --instances, or mark it --default.',
        );
      if (unresolved.length > 0)
        logger.warn('Hostnames with no spoke binding; they will fall through to the default backend or be refused', {
          unresolved,
        });
      if (conflicts.length > 0)
        logger.warn('Hostnames claimed by more than one deploy; only the first is served', { conflicts });
      const state = readEdgeState();
      const defaultPeer = defaultPeerFactory(state.peers);
      const maps = haproxyMapsFactory({ routes });
      const conf = haproxyConfFactory({
        peers,
        defaultPeerId: defaultPeer?.id || '',
        sshForwardPort: state.sshForwardPort,
      });

      const targets = [
        { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`, content: maps.sni },
        { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`, content: maps.http },
        { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`, content: conf },
      ];
      const previous = targets.map(({ target }) => ({
        target,
        content: fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null,
      }));
      const restore = () => {
        for (const entry of previous) {
          if (entry.content === null) shellExec(`sudo rm -f ${entry.target}`, { silent: true });
          else writeRootFile({ target: entry.target, content: entry.content, mode: '0644' });
        }
      };

      let changed = false;
      for (const entry of targets) changed = writeRootFile({ ...entry, dryRun: options.dryRun }) || changed;
      if (options.dryRun) {
        logger.info('[dry-run] edge routes', { deployList, routes, unresolved, conflicts });
        return { routes, unresolved, conflicts, changed };
      }

      const check = shellExec(
        `sudo haproxy -c -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName} 2>&1`,
        { stdout: true, silent: true, silentOnError: true },
      );
      if (!`${check}`.includes('Configuration file is valid')) {
        restore();
        const detail = `${check}`.trim().split('\n').slice(-3).join(' ');
        logger.error('HAProxy rejected the candidate config; the previous config was restored', { detail });
        throw new Error(`[wireguard] HAProxy rejected the generated config: ${detail}`);
      }
      // Reload only a daemon that is already up: on a first bring-up the config
      // is written before `--haproxy-setup` enables the service, and reloading a
      // stopped unit is a failure rather than a no-op.
      if (changed) shellExec(systemdReloadIfActiveCommandFactory('haproxy'), { silent: true });

      const quicTarget = defaultPeer?.address || '';
      runHostCommands(
        quicForwardCommandsFactory({ interfaceName: state.interfaceName, target: quicTarget }).ensure,
        options.dryRun,
      );
      logger.info('Edge routes published', {
        deployList,
        routes: routes.length,
        unresolved: unresolved.length,
        conflicts: conflicts.length,
        reloaded: changed,
        quicTarget,
      });
      return { routes, unresolved, conflicts, changed };
    },

    /**
     * @method haproxySetup
     * @description Installs HAProxy, publishes the current routes, and enables
     * the daemon so the edge survives a reboot.
     * @param {object} options - CLI options.
     * @returns {void}
     * @memberof UnderpostWireguard
     */
    haproxySetup(options = {}) {
      UnderpostWireguard.API.install(options);
      UnderpostWireguard.API.haproxySync(options);
      runServiceCommands([systemctlCommandFactory({ action: 'enable --now', name: 'haproxy' })], options.dryRun);
      logger.info('HAProxy edge gateway enabled');
    },

    /**
     * @method forwardProxyConfig
     * @description Resolves and validates the hub proxy endpoint.
     * @param {object} [options] - CLI options.
     * @returns {{host: string, port: number, apiKey: string, address: string, interfaceName: string, listenPort: number, tunnelCidr: string}} Resolved endpoint; `apiKey` must never be logged.
     * @throws {Error} When run on a spoke, or with no key configured.
     * @memberof UnderpostWireguard
     */
    forwardProxyConfig(options = {}) {
      const state = readEdgeState();
      if (state.role !== 'server')
        throw new Error(
          state.role === 'client'
            ? '[wireguard] --forward-proxy-server runs on the hub: a spoke would relay through its own ISP address'
            : '[wireguard] --forward-proxy-server requires a configured hub; run --wireguard-setup --server first',
        );
      const config = forwardProxyConfigFactory({
        host: `${options.forwardProxyServerHost || ''}`.trim() || tunnelAddressFactory(state.address),
        port: options.forwardProxyServerPort,
      });
      if (!config.apiKey)
        throw new Error(
          `[wireguard] ${FORWARD_PROXY.env.apiKey} is not set; every proxied request is authenticated with it. ` +
            `Export it, put it in the deploy env selected by \`underpost env <deploy-id> <environment>\` (./.env), ` +
            `or set it with \`underpost env set ${FORWARD_PROXY.env.apiKey} <key>\``,
        );
      if (['0.0.0.0', '::', '*'].includes(config.host))
        logger.warn('Forward proxy is bound to a wildcard address, not the tunnel', {
          host: config.host,
          consequence: 'the proxy is reachable from every interface; only the API key refuses a request',
          instead: `--forward-proxy-server-host ${tunnelAddressFactory(state.address) || tunnelAddressFactory(UNDERPOST_EDGE.cidr)}`,
        });
      return {
        ...config,
        address: `${config.host}:${config.port}`,
        interfaceName: state.interfaceName,
        listenPort: state.listenPort,
        tunnelCidr: tunnelNetworkCidrFactory(state.address),
      };
    },

    /**
     * @method forwardProxyNodePath
     * @description Selects a compatible Node binary that systemd can execute.
     * @returns {{path: string, probed: boolean, rejected: Array<object>}} The chosen binary, and why each earlier candidate was passed over.
     * @memberof UnderpostWireguard
     */
    forwardProxyNodePath() {
      const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
      const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
      const requiredMajor = Number(`${process.versions.node}`.split('.')[0]) || 0;
      const probed = read(systemdAvailableCommandFactory()) !== '';
      const user = os.userInfo().username;
      const rejected = [];
      for (const candidate of forwardProxyNodeCandidatesFactory()) {
        if (!ok(`sudo test -x ${candidate}`)) {
          rejected.push({ candidate, reason: 'not present' });
          continue;
        }
        const major = Number(read(`sudo ${candidate} --version`).replace(/^v/, '').split('.')[0]) || 0;
        if (major < requiredMajor) {
          rejected.push({ candidate, reason: `runs Node v${major || '?'}, the engine needs v${requiredMajor}` });
          continue;
        }
        if (probed && !ok(forwardProxyNodeProbeCommandFactory(candidate, user))) {
          rejected.push({
            candidate,
            reason: homeDirectoryPathFactory(candidate)
              ? 'systemd cannot execute it: it is under a home directory, whose SELinux label a unit cannot enter'
              : 'systemd cannot execute it',
          });
          continue;
        }
        return { path: candidate, probed, rejected };
      }
      return { path: '', probed, rejected };
    },

    /**
     * @method forwardProxyServer
     * @description Reconciles the supervised proxy service.
     * @param {object} [options] - CLI options.
     * @returns {?object} What was reconciled, or null under `--dry-run`.
     * @throws {Error} When run on a spoke, or with no key configured.
     * @memberof UnderpostWireguard
     */
    forwardProxyServer(options = {}) {
      if (`${process.env[FORWARD_PROXY.supervisedEnv] || ''}`.trim())
        return UnderpostWireguard.API.forwardProxyListen(options);

      const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
      const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
      const withdrawBrokenUnit = () => {
        if (fs.existsSync(FORWARD_PROXY.unitPath))
          runServiceCommands(forwardProxyServiceCommandsFactory().remove, false);
      };
      const config = UnderpostWireguard.API.forwardProxyConfig(options);
      const node = options.dryRun
        ? { path: process.execPath, probed: false, rejected: [] }
        : UnderpostWireguard.API.forwardProxyNodePath();
      if (!node.path) {
        logger.error('No Node binary the forward proxy service can execute', {
          requires: `Node v${`${process.versions.node}`.split('.')[0]}`,
          rejected: node.rejected,
          fix: 'install Node system-wide, then re-run: curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - && sudo dnf install -y nodejs',
        });
        withdrawBrokenUnit();
        throw new Error(
          '[wireguard] No Node binary the forward proxy service can execute; install Node system-wide (a binary under /root or /home cannot be run by a unit) and re-run',
        );
      }
      if (node.probed === false && homeDirectoryPathFactory(node.path))
        logger.warn('Forward proxy unit points at a Node binary under a home directory', {
          node: node.path,
          risk: 'systemd refuses those on an SELinux host and the service restarts on 203/EXEC',
          fix: 'install Node system-wide, or check `journalctl -u underpost-forward-proxy` after this run',
        });
      if (node.probed && !ok(forwardProxyStartProbeCommandFactory({ nodePath: node.path }))) {
        logger.error('The forward proxy service cannot start this checkout', {
          node: node.path,
          cli: process.argv[1],
          workingDirectory: process.cwd(),
          likely: homeDirectoryPathFactory(process.cwd())
            ? 'the checkout is under a home directory, which a unit cannot read on an SELinux host'
            : 'the service cannot read the checkout; see the journal for the failing command',
          check: `sudo ${forwardProxyStartProbeCommandFactory({ nodePath: node.path }).replace(/^sudo /, '')}`,
        });
        withdrawBrokenUnit();
        throw new Error(
          `[wireguard] A systemd unit cannot start ${process.argv[1]} from ${process.cwd()}; move the checkout out of a home directory (e.g. /opt/underpost/engine) and re-run`,
        );
      }
      runHostCommands(
        firewallCommandsFactory({
          role: 'server',
          interfaceName: config.interfaceName,
          listenPort: config.listenPort,
          tunnelCidr: config.tunnelCidr,
        }),
        options.dryRun,
      );
      const unit = forwardProxyUnitFactory({
        host: config.host,
        port: config.port,
        apiKey: config.apiKey,
        interfaceName: config.interfaceName,
        command: forwardProxyCommandFactory({ host: config.host, port: config.port, execPath: node.path }),
      });
      const changed = writeRootFile({
        target: FORWARD_PROXY.unitPath,
        content: unit,
        mode: '0600',
        dryRun: options.dryRun,
      });
      runServiceCommands(forwardProxyServiceCommandsFactory({ changed }).ensure, options.dryRun);
      if (options.dryRun) {
        logger.info('[dry-run] would reconcile the forward proxy service', {
          service: FORWARD_PROXY.serviceName,
          address: config.address,
        });
        return null;
      }
      const statusCommands = systemdStatusCommandsFactory(FORWARD_PROXY.serviceName);
      const state = read(statusCommands.active);
      const enabled = read(statusCommands.enabled);
      logger.info('Forward proxy service reconciled', {
        service: FORWARD_PROXY.serviceName,
        address: config.address,
        tunnel: config.tunnelCidr,
        node: node.path,
        unitChanged: changed,
        state,
        enabled,
        logs: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, follow: true }),
      });
      const running = state === 'active' || state === 'activating';
      if (!running) {
        logger.error('Forward proxy service did not come up', {
          state: state || '(unknown)',
          likely: `${config.host} does not exist yet, so the listener cannot bind — bring the tunnel up with --wireguard-start`,
          check: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, lines: 20 }),
        });
        process.exitCode = 1;
      }
      return { service: FORWARD_PROXY.serviceName, address: config.address, changed, state, enabled };
    },

    /**
     * @method forwardProxyListen
     * @description Starts the authenticated listener on the resolved hub address.
     * @param {object} [options] - CLI options.
     * @returns {object} The listening server.
     * @throws {Error} When run on a spoke, or with no key configured.
     * @memberof UnderpostWireguard
     */
    forwardProxyListen(options = {}) {
      const config = UnderpostWireguard.API.forwardProxyConfig(options);
      let server;
      server = forwardProxyServerFactory({
        config,
        onError(error) {
          logger.error('Forward proxy listener failed', { address: config.address, message: error.message });
          process.exitCode = 1;
          server.close(() => {});
        },
        onListen() {
          logger.info('Forward proxy listening', { address: config.address, tunnel: UNDERPOST_EDGE.tunnelCidr });
        },
      });
      return server;
    },

    /**
     * @method start
     * @description Enables and starts the tunnel, and the QUIC forward with it.
     * @param {object} options - CLI options.
     * @returns {void}
     * @memberof UnderpostWireguard
     */
    start(options = {}) {
      const state = readEdgeState();
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      runServiceCommands(
        [systemctlCommandFactory({ action: 'enable --now', name: `wg-quick@${interfaceName}` })],
        options.dryRun,
      );
      if (state.role === 'server')
        runHostCommands(
          quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
          options.dryRun,
        );
      logger.info('WireGuard interface started', { interfaceName });
    },

    /**
     * @method stop
     * @description Tears the tunnel down and removes the packet rules that only
     * make sense while it is up.
     * @param {object} options - CLI options.
     * @returns {void}
     * @memberof UnderpostWireguard
     */
    stop(options = {}) {
      const state = readEdgeState();
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      runHostCommands(
        [
          systemctlCommandFactory({
            action: 'disable --now',
            name: `wg-quick@${interfaceName}`,
            stderr: true,
            allowFailure: true,
          }),
          `sudo sh -c 'wg-quick down ${interfaceName} 2>/dev/null || true'`,
          ...quicForwardCommandsFactory({ interfaceName }).remove,
        ],
        options.dryRun,
      );
      logger.info('WireGuard interface stopped', { interfaceName });
    },

    /**
     * @method reset
     * @description Returns the host to zero: stops the daemons and withdraws
     * every artifact the setup installed.
     *
     * Everything this subsystem writes outside the repo is removed — interface
     * config, sysctl drop-in, both HAProxy map files *and* the generated
     * `haproxy.cfg`, the forward proxy unit, the NAT chains, and the firewalld
     * rules opened for the recorded role. Leaving `haproxy.cfg` behind while
     * deleting the maps it reads is worse than leaving both: the daemon then
     * fails to start on a config that references files that no longer exist.
     *
     * The key pair and the registry are deliberately kept: destroying the key
     * invalidates every spoke's peer entry, and the registry is authored source
     * that nothing can regenerate. A reset is for reconfiguring an edge rather
     * than for re-establishing trust with all of them; re-keying is what
     * {@link UnderpostWireguard.reinstall} is for.
     * @param {object} options - CLI options.
     * @returns {void}
     * @memberof UnderpostWireguard
     */
    reset(options = {}) {
      const state = readEdgeState();
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      UnderpostWireguard.API.stop({ ...options, interface: interfaceName });
      runHostCommands(
        [
          `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
          `sudo rm -f ${UNDERPOST_EDGE.sysctlPath}`,
          systemctlCommandFactory({
            action: 'disable --now',
            name: 'haproxy',
            stderr: true,
            allowFailure: true,
          }),
          `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`,
          `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`,
          `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`,
          ...forwardProxyServiceCommandsFactory().remove,
          ...(state.role
            ? firewallCommandsFactory({
                role: state.role,
                interfaceName,
                listenPort: state.listenPort,
                tunnelCidr: tunnelNetworkCidrFactory(state.address),
                sshForwardPort: state.sshForwardPort,
                remove: true,
              })
            : []),
        ],
        options.dryRun,
      );
      logger.info('Edge host state removed; key pair and peer registry retained', {
        interfaceName,
        role: state.role || '(unset)',
        firewallWithdrawn: state.role !== '',
      });
      if (!state.role)
        logger.warn('Registry records no role, so no firewalld rules were withdrawn', {
          fix: `--wireguard-reset --interface ${interfaceName} after the role is recorded, or withdraw them by hand`,
        });
    },

    /**
     * @method reinstall
     * @description Full purge and rebuild: reset, drop the key pair, reinstall
     * the packages, then re-key and republish.
     *
     * Every spoke has to be re-registered afterwards, because the hub's identity
     * changed — that is the point of the command, and the reason it is separate
     * from `--wireguard-reset`.
     * @param {object} options - CLI options.
     * @returns {void}
     * @memberof UnderpostWireguard
     */
    reinstall(options = {}) {
      const state = readEdgeState();
      const interfaceName = `${options.interface || state.interfaceName}`.trim();
      UnderpostWireguard.API.reset({ ...options, interface: interfaceName });
      runHostCommands(
        [
          `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
          `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`,
          `sudo dnf -y reinstall ${UNDERPOST_EDGE.packages.join(' ')} || sudo dnf -y install ${UNDERPOST_EDGE.packages.join(' ')}`,
        ],
        options.dryRun,
      );
      const next = UnderpostWireguard.API.setup({ ...options, interface: interfaceName });
      logger.warn('Re-keyed: this machine now presents a new identity, and the far end still expects the old one', {
        interfaceName,
        publicKey: next.publicKey,
        onEverySpoke:
          next.role === 'server'
            ? `--wireguard-setup --client --public-key '${next.publicKey}'   (peer-ip and endpoint are remembered)`
            : 'none',
        onTheHub:
          next.role === 'client'
            ? `--peer-add <this spoke id> --peer-ip ${next.address} --public-key '${next.publicKey}'`
            : 'none',
      });
    },

    /**
     * @method callback
     * @description CLI entry point for both `underpost wireguard` and
     * `underpost haproxy`.
     *
     * Flags are evaluated in lifecycle order — install, setup, peer changes,
     * route publication, then daemon control — so a single invocation can carry
     * a whole bring-up (`--wireguard-install --wireguard-setup --server
     * --haproxy-setup --wireguard-start`) and still execute the steps in the only
     * order that works. `--status` runs last, so it reports what the run left
     * behind.
     * @param {object} [options] - CLI options.
     * @returns {Promise<void>}
     * @memberof UnderpostWireguard
     */
    async callback(options = {}) {
      // `--build-conf` is a hard promise, not a modifier: it short-circuits
      // every host action so the run cannot touch /etc, iptables, systemd or a
      // live interface even when other lifecycle flags are also present.
      if (options.buildConf === true) {
        if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
        if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
        if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
        if (!options.wireguardSetup && !options.peerAdd && !options.peerRemove)
          UnderpostWireguard.API.buildConf(options);
        if (options.status === true) UnderpostWireguard.API.status(options);
        return;
      }

      if (options.wireguardReinstall === true) return void UnderpostWireguard.API.reinstall(options);
      if (options.wireguardReset === true) return void UnderpostWireguard.API.reset(options);
      if (options.wireguardInstall === true) UnderpostWireguard.API.install(options);
      if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
      if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
      if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
      if (options.haproxySetup === true) UnderpostWireguard.API.haproxySetup(options);
      else if (options.haproxySync === true) UnderpostWireguard.API.haproxySync(options);
      if (options.wireguardStop === true) UnderpostWireguard.API.stop(options);
      if (options.wireguardStart === true) UnderpostWireguard.API.start(options);
      // After the tunnel, because the service requires it and its address only
      // exists once the interface is up; before `--status`, so a run that
      // reconciles the service also reports it.
      if (options.forwardProxyServer === true) UnderpostWireguard.API.forwardProxyServer(options);
      if (options.status === true) UnderpostWireguard.API.status(options);
    },
  };
}

export {
  EDGE_STATE_PATH,
  UNDERPOST_EDGE,
  allowedIpsConflictsFactory,
  backendNameFactory,
  defaultPeerFactory,
  deployListFactory,
  edgeRouteTableFactory,
  edgeStateFactory,
  firewallCommandsFactory,
  haproxyConfFactory,
  haproxyMapsFactory,
  hostProxyEntriesFactory,
  instanceProxyEntriesFactory,
  mergeRouteTablesFactory,
  peerFactory,
  quicForwardCommandsFactory,
  readEdgeState,
  redirectHostFactory,
  tunnelAddressFactory,
  tunnelNetworkCidrFactory,
  wireguardClientConfFactory,
  wireguardClientSettingsFactory,
  wireguardServerConfFactory,
  wireguardStatusFactory,
  writeEdgeState,
};

export default UnderpostWireguard;