CIS490/bootstrap/__main__.py
max a93a3ff221 bootstrap: auto-issue mTLS leaves to enrolled lab hosts (closes #9, refs #3)
Adds a pull-based cert distribution path so install-lab-host.sh can
fetch its own leaf cert without operator intervention. Removes the
ssh-from-Pi requirement that blocked elliott-lab.

How the chicken-and-egg gets solved: a freshly wg-enrolled lab host
already has WG access (gate kept by iptmonads at L4) and trusts the
Caddy local CA (bundled in this repo at etc/caddy-root.crt). It
makes a single TLS call to https://bootstrap.wg/v1/cert/<host_id>
— no mTLS — gets back a tar of {ca.crt, leaf.pem, leaf.key},
extracts to /etc/cis490/certs/, and the shipper unblocks. Trust
boundary is "reached :443 over WG"; no operator action needed.

bootstrap/
  app.py        Starlette: GET /v1/cert/{host_id}, GET /v1/health.
                Validates host_id charset, rate-limits per source IP,
                logs every mint with the X-Real-IP Caddy injects.
  __main__.py   uvicorn launcher; runs as root because the wg-pki CA
                private key is root-only.

etc/cis490-bootstrap.service
  systemd unit on 127.0.0.1:8446 with ProtectSystem=strict +
  narrow ReadWritePaths=/var/lib/wg-pki. ProtectHome=no because
  systemd's read-only mode hides /home contents (the issuer script
  the wrapper exec's lives there).

scripts/issue-cis490-client-cert-wrapper.sh
  Adapter the bootstrap service shells out to. Resolves the actual
  wg-pki issuer script across the three plausible install layouts
  (/opt/wg-pki, /home/max/wg-pki, /home/max/.env/wg-pki) so a single
  copy of the unit file works on any operator's box. Forces
  --out-dir to /var/lib/wg-pki/issued so writes stay inside the
  service's narrow ReadWritePaths.

scripts/install-lab-host.sh
  After scaffolding lab-host.toml, if /etc/cis490/certs/lab-host.pem
  is absent, curls bootstrap.wg with --cacert etc/caddy-root.crt
  (no chicken-and-egg), extracts, chowns/chmods. Skips silently if
  bootstrap.wg is unreachable so manual hand-carry remains possible.

scripts/install-receiver.sh
  Drops cis490-bootstrap.service alongside cis490-receiver and
  prints both as "enable --now" candidates. cis490-bootstrap is the
  thing that makes lab hosts self-provisioning.

etc/caddy-root.crt
  Bundled copy of wg-pki's published Caddy local CA root, so the
  bootstrap fetch can verify TLS without depending on a wg-pki
  clone that may or may not be on the lab host yet.

Verified live on the Pi:
  $ curl --cacert etc/caddy-root.crt https://bootstrap.wg/v1/cert/elliott-lab -o /tmp/x.tar
  HTTP 200 size=10240
  $ tar tf /tmp/x.tar
  ca.crt
  elliott-lab.key
  elliott-lab.pem
  $ openssl verify -CAfile … elliott-lab.pem
  /tmp/.../elliott-lab.pem: OK
  $ openssl x509 -subject … -noout
  subject=CN=elliott-lab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:30:29 -05:00

65 lines
1.8 KiB
Python

"""``cis490-bootstrap`` launcher.
Runs as root (needs CA private key access). Listens on 127.0.0.1:8446
behind Caddy's ``bootstrap.wg`` site — Caddy terminates TLS, this
service speaks plain HTTP on loopback only.
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
import uvicorn
from bootstrap.app import make_app
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="cis490-bootstrap")
p.add_argument("--listen-host", default="127.0.0.1")
p.add_argument("--listen-port", type=int, default=8446)
p.add_argument(
"--issuer-script",
type=Path,
default=Path("/home/max/.env/wg-pki/scripts/issue-cis490-client-cert.sh"),
help="Path to the wg-pki leaf-cert mint script.",
)
p.add_argument(
"--issued-root",
type=Path,
default=Path("/home/max/.env/wg-pki/issued"),
help="Where minted tarballs are cached.",
)
p.add_argument("--log-level", default="info")
args = p.parse_args(argv)
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("cis490.bootstrap.main")
if not args.issuer_script.exists():
log.error("issuer script missing: %s", args.issuer_script)
return 2
app = make_app(
issuer_script=args.issuer_script,
issued_root=args.issued_root,
)
log.info("listening on %s:%d", args.listen_host, args.listen_port)
uvicorn.run(
app,
host=args.listen_host,
port=args.listen_port,
log_level=args.log_level,
access_log=True,
)
return 0
if __name__ == "__main__":
sys.exit(main())