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>
This commit is contained in:
parent
6f8b744c33
commit
a93a3ff221
8 changed files with 361 additions and 3 deletions
0
bootstrap/__init__.py
Normal file
0
bootstrap/__init__.py
Normal file
65
bootstrap/__main__.py
Normal file
65
bootstrap/__main__.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""``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())
|
||||
146
bootstrap/app.py
Normal file
146
bootstrap/app.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""``cis490-bootstrap`` — auto-issue mTLS leaf certs to enrolled lab hosts.
|
||||
|
||||
This is the chicken-and-egg fix for first-time lab-host setup. A
|
||||
freshly wg-enrolled device has WG access (and trusts the wg-pki CA)
|
||||
but has no client cert yet, so it can't authenticate to the
|
||||
mTLS-protected ``collector.wg``. This service exposes a *plain-TLS*
|
||||
(no client-auth) endpoint that the lab host can call once during
|
||||
``install-lab-host.sh`` to retrieve its leaf cert tarball.
|
||||
|
||||
Trust boundary: anything that reaches ``bootstrap.wg`` has already
|
||||
passed iptmonads' WG-membership check at L4. No further
|
||||
authentication is required for the bootstrap pull — by the time a
|
||||
caller can connect at all they're a peer the operator authorized.
|
||||
|
||||
The privilege boundary, on the other hand, is real: minting certs
|
||||
requires the wg-pki CA private key (root-only at
|
||||
``/var/lib/wg-pki/cis490-client-ca/ca.key``). This service therefore
|
||||
runs as root in a tight sandbox (see ``etc/cis490-bootstrap.service``)
|
||||
and shells out to ``issue-cis490-client-cert.sh`` for each mint.
|
||||
|
||||
Endpoints:
|
||||
|
||||
GET /v1/cert/{host_id} — return tarball of {ca.crt, leaf.pem, leaf.key}
|
||||
for ``host_id``. Cached — successive calls
|
||||
return the same bytes.
|
||||
GET /v1/health — liveness probe (no auth needed).
|
||||
|
||||
Each mint is logged with the source IP (after Caddy's X-Real-IP
|
||||
forward) so the operator has an audit trail of which devices have
|
||||
fetched which certs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
|
||||
log = logging.getLogger("cis490.bootstrap")
|
||||
|
||||
|
||||
# Sane host_id charset — same rules the receiver enforces, mirrored
|
||||
# here so mint requests can't smuggle path traversal in.
|
||||
_HOST_ID_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
|
||||
|
||||
def _is_valid_host_id(s: str) -> bool:
|
||||
return bool(_HOST_ID_RE.match(s))
|
||||
|
||||
|
||||
def make_app(
|
||||
*,
|
||||
issuer_script: Path,
|
||||
issued_root: Path,
|
||||
rate_limit_window_s: float = 5.0,
|
||||
) -> Starlette:
|
||||
"""Build the Starlette app. Wired by the production launcher in
|
||||
``bootstrap/__main__.py``; tests can pass synthetic paths."""
|
||||
issued_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Coarse per-IP rate limiter to make a casual scan annoying. Not
|
||||
# a real defense — the WG mesh is the actual perimeter.
|
||||
last_request: dict[str, float] = {}
|
||||
|
||||
async def health(request: Request) -> Response:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
async def get_cert(request: Request) -> Response:
|
||||
host_id: str = request.path_params["host_id"]
|
||||
if not _is_valid_host_id(host_id):
|
||||
return JSONResponse({"error": "bad host_id"}, status_code=400)
|
||||
|
||||
# Caddy forwards the original WG-side IP via X-Real-IP /
|
||||
# X-Forwarded-For; fall back to the direct peer if running
|
||||
# without Caddy in front (tests).
|
||||
src = (
|
||||
request.headers.get("x-real-ip")
|
||||
or (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
|
||||
or (request.client.host if request.client else "?")
|
||||
)
|
||||
|
||||
now = time.monotonic()
|
||||
prev = last_request.get(src, 0.0)
|
||||
if (now - prev) < rate_limit_window_s:
|
||||
return JSONResponse(
|
||||
{"error": "rate limited; back off"},
|
||||
status_code=429,
|
||||
)
|
||||
last_request[src] = now
|
||||
|
||||
tar_path = issued_root / host_id / f"{host_id}.tar"
|
||||
if not tar_path.exists():
|
||||
log.info("minting cert for host_id=%s src=%s", host_id, src)
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
str(issuer_script), host_id,
|
||||
"--out-dir", str(issued_root / host_id),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.error("issue script failed for %s: rc=%d stderr=%s",
|
||||
host_id, e.returncode, e.stderr[:500])
|
||||
return JSONResponse(
|
||||
{"error": "mint failed", "detail": e.stderr[:500]},
|
||||
status_code=500,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
log.exception("issue script transport error for %s", host_id)
|
||||
return JSONResponse(
|
||||
{"error": f"transport: {e}"},
|
||||
status_code=500,
|
||||
)
|
||||
else:
|
||||
log.info("cache hit for host_id=%s src=%s", host_id, src)
|
||||
|
||||
if not tar_path.exists():
|
||||
return JSONResponse({"error": "tarball not produced"}, status_code=500)
|
||||
return FileResponse(
|
||||
tar_path,
|
||||
media_type="application/x-tar",
|
||||
filename=f"{host_id}.tar",
|
||||
headers={
|
||||
"X-Cis490-Host-Id": host_id,
|
||||
"X-Cis490-Cert-Source-IP": src,
|
||||
},
|
||||
)
|
||||
|
||||
routes = [
|
||||
Route("/v1/health", health, methods=["GET"]),
|
||||
Route("/v1/cert/{host_id}", get_cert, methods=["GET"]),
|
||||
]
|
||||
return Starlette(routes=routes)
|
||||
11
etc/caddy-root.crt
Normal file
11
etc/caddy-root.crt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBpDCCAUqgAwIBAgIRAP15YNZS/guq4ES7RfuBBQQwCgYIKoZIzj0EAwIwMDEu
|
||||
MCwGA1UEAxMlQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDAe
|
||||
Fw0yNjA0MjYxMzE5NTZaFw0zNjAzMDQxMzE5NTZaMDAxLjAsBgNVBAMTJUNhZGR5
|
||||
IExvY2FsIEF1dGhvcml0eSAtIDIwMjYgRUNDIFJvb3QwWTATBgcqhkjOPQIBBggq
|
||||
hkjOPQMBBwNCAASjU+sJ+rLPPtTK5t7MsKa6/WDknumPOgxy7uGwGATkd65cHTjz
|
||||
zTH6+0+uJ7LPZFTJoPSB5WVHrEA0veY8AxH5o0UwQzAOBgNVHQ8BAf8EBAMCAQYw
|
||||
EgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQU8EarYtjVc2EvpYE6OPhDQlYB
|
||||
docwCgYIKoZIzj0EAwIDSAAwRQIhANxALV9oKSAC4JEB/w1EctnzMfzLyueBpGoB
|
||||
7p5I07LRAiAKQuhNMeTDSK3Qql+IjunH8UPidETNXfyInwMnbzgAaQ==
|
||||
-----END CERTIFICATE-----
|
||||
44
etc/cis490-bootstrap.service
Normal file
44
etc/cis490-bootstrap.service
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
[Unit]
|
||||
Description=CIS490 mTLS bootstrap endpoint (auto-issue client certs to enrolled lab hosts)
|
||||
Documentation=https://maxgit.wg/spectral/CIS490
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Runs as root because the wg-pki CA private key is root-only. The
|
||||
# service shells out to issue-cis490-client-cert.sh per mint and
|
||||
# never touches anything else under /var/lib.
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/cis490
|
||||
ExecStart=/opt/cis490/.venv/bin/python -m bootstrap \
|
||||
--listen-host 127.0.0.1 \
|
||||
--listen-port 8446 \
|
||||
--issuer-script /opt/wg-pki/scripts/issue-cis490-client-cert-wrapper.sh \
|
||||
--issued-root /var/lib/wg-pki/issued
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening — narrower than receiver because this binary's only job
|
||||
# is to call openssl + tar via the issuer script, then serve files.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
# /home/max/.env/wg-pki/scripts/ holds the issuer script the wrapper
|
||||
# exec's. ProtectHome={read-only,tmpfs} both *hide* /home contents
|
||||
# instead of restricting them to read-only — so we leave /home
|
||||
# accessible. ProtectSystem=strict still keeps everything outside
|
||||
# /var/lib/wg-pki write-protected.
|
||||
ProtectHome=no
|
||||
ReadWritePaths=/var/lib/wg-pki
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -112,7 +112,43 @@ FLEET_HOST_ID=$DEFAULT_HOST_ID
|
|||
EOF
|
||||
fi
|
||||
|
||||
# --- 7. baseline VM image + cidata (best-effort) -----------------------
|
||||
# --- 7. mTLS leaf cert (auto-fetch via bootstrap.wg) -------------------
|
||||
# Pull our leaf cert from the Pi's bootstrap endpoint if it isn't
|
||||
# already on disk. Trust boundary: "reached bootstrap.wg over WG"
|
||||
# (iptmonads already filters non-peers from 443). Caddy's TLS cert
|
||||
# is verified against the bundled etc/caddy-root.crt — no chicken-
|
||||
# and-egg.
|
||||
HOST_ID="$(grep -E '^host_id\s*=' "$ETC_ROOT/lab-host.toml" 2>/dev/null \
|
||||
| head -1 | sed -E 's/^host_id\s*=\s*"([^"]+)".*/\1/')"
|
||||
if [[ -z "$HOST_ID" || "$HOST_ID" == "REPLACE_ME" ]]; then
|
||||
log "skipping cert auto-fetch: host_id not set in $ETC_ROOT/lab-host.toml"
|
||||
elif [[ ! -f "$ETC_ROOT/certs/lab-host.pem" ]]; then
|
||||
log "fetching leaf cert from https://bootstrap.wg/v1/cert/$HOST_ID"
|
||||
install -d -m 0755 -o root -g "$SERVICE_USER" "$ETC_ROOT/certs"
|
||||
TAR="/tmp/cis490-bootstrap-$$.tar"
|
||||
if curl -fsS --cacert "$REPO_ROOT/etc/caddy-root.crt" \
|
||||
--connect-timeout 10 --max-time 60 \
|
||||
"https://bootstrap.wg/v1/cert/$HOST_ID" -o "$TAR"; then
|
||||
tar -C "$ETC_ROOT/certs" -xf "$TAR"
|
||||
mv "$ETC_ROOT/certs/ca.crt" "$ETC_ROOT/certs/wg-ca.pem"
|
||||
mv "$ETC_ROOT/certs/$HOST_ID.pem" "$ETC_ROOT/certs/lab-host.pem"
|
||||
mv "$ETC_ROOT/certs/$HOST_ID.key" "$ETC_ROOT/certs/lab-host.key"
|
||||
chown root:"$SERVICE_USER" "$ETC_ROOT/certs/"*.pem \
|
||||
"$ETC_ROOT/certs/lab-host.key"
|
||||
chmod 0644 "$ETC_ROOT/certs/"*.pem
|
||||
chmod 0640 "$ETC_ROOT/certs/lab-host.key"
|
||||
rm -f "$TAR"
|
||||
log "leaf cert installed for host_id=$HOST_ID"
|
||||
else
|
||||
rm -f "$TAR"
|
||||
log "WARN: bootstrap.wg fetch failed — make sure /etc/hosts maps it"
|
||||
log " to 10.100.0.1 and that wg0 is up. cert delivery skipped."
|
||||
fi
|
||||
else
|
||||
log "$ETC_ROOT/certs/lab-host.pem present; skipping auto-fetch"
|
||||
fi
|
||||
|
||||
# --- 8. baseline VM image + cidata (best-effort) -----------------------
|
||||
ALPINE_IMG="$DATA_ROOT/vm/images/alpine-baseline.qcow2"
|
||||
CIDATA_ISO="$DATA_ROOT/vm/images/cidata.iso"
|
||||
if [[ ! -f "$ALPINE_IMG" ]]; then
|
||||
|
|
|
|||
|
|
@ -75,9 +75,11 @@ else
|
|||
fi
|
||||
|
||||
# --- 4. systemd --------------------------------------------------------
|
||||
log "installing systemd unit"
|
||||
log "installing systemd units (receiver + bootstrap)"
|
||||
install -m 0644 "$REPO_ROOT/etc/cis490-receiver.service" \
|
||||
/etc/systemd/system/cis490-receiver.service
|
||||
install -m 0644 "$REPO_ROOT/etc/cis490-bootstrap.service" \
|
||||
/etc/systemd/system/cis490-bootstrap.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- 5. config template (only on first install) -----------------------
|
||||
|
|
@ -97,8 +99,12 @@ if [[ ! -f "$ETC_ROOT/receiver.toml" ]]; then
|
|||
log " (mTLS to clients is enforced by the wg-pki CA bundle on"
|
||||
log " the receiver side once leaf certs are issued.)"
|
||||
log " 3. Open the WG-side port via iptmonads."
|
||||
log " 4. systemctl enable --now cis490-receiver"
|
||||
log " 4. systemctl enable --now cis490-receiver cis490-bootstrap"
|
||||
log " 5. From a lab host: cis490-shipper --ping"
|
||||
log ""
|
||||
log "Bootstrap endpoint (cis490-bootstrap on :8446 + Caddy bootstrap.wg)"
|
||||
log "lets enrolled lab hosts auto-fetch their leaf certs. Without it,"
|
||||
log "operators have to hand-carry tarballs via deploy-cis490-cert.sh."
|
||||
else
|
||||
log "$ETC_ROOT/receiver.toml exists; leaving in place"
|
||||
fi
|
||||
|
|
|
|||
50
scripts/issue-cis490-client-cert-wrapper.sh
Executable file
50
scripts/issue-cis490-client-cert-wrapper.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
# Wrapper that re-points the wg-pki issuer script's relative-path
|
||||
# assumption (PWD-derived publish dir, $REPO_ROOT/issued/) to the
|
||||
# absolute /var/lib/wg-pki/issued/ that the bootstrap service uses.
|
||||
#
|
||||
# wg-pki ships the actual issuer at
|
||||
# /home/max/.env/wg-pki/scripts/issue-cis490-client-cert.sh, which
|
||||
# computes paths relative to its own location. This wrapper sets
|
||||
# WG_PKI_STATE so the CA key is found in /var/lib/wg-pki, and forces
|
||||
# --out-dir to a path under /var/lib so cis490-bootstrap (with
|
||||
# ProtectHome=tmpfs) can write the resulting tarballs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve issuer path: prefer the install-time copy at /opt/wg-pki/,
|
||||
# fall back to whatever wg-pki clone the operator has under /home.
|
||||
ISSUER="${WG_PKI_ISSUER:-}"
|
||||
if [[ -z "$ISSUER" ]]; then
|
||||
for cand in \
|
||||
/opt/wg-pki/scripts/issue-cis490-client-cert.sh \
|
||||
/home/max/wg-pki/scripts/issue-cis490-client-cert.sh \
|
||||
/home/max/.env/wg-pki/scripts/issue-cis490-client-cert.sh; do
|
||||
if [[ -x "$cand" ]]; then ISSUER="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$ISSUER" || ! -x "$ISSUER" ]]; then
|
||||
echo "wrapper: no issue-cis490-client-cert.sh found; tried /opt/wg-pki, /home/max/wg-pki, /home/max/.env/wg-pki" >&2
|
||||
exit 2
|
||||
fi
|
||||
OUT_ROOT="/var/lib/wg-pki/issued"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "usage: $0 <host_id> [--out-dir DIR] [--days N]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HOST_ID="$1"; shift
|
||||
|
||||
# Pull off any --out-dir already passed; we override.
|
||||
EXTRA=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--out-dir) shift 2 ;; # drop, we set it ourselves
|
||||
*) EXTRA+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$OUT_ROOT/$HOST_ID"
|
||||
exec env WG_PKI_STATE=/var/lib/wg-pki \
|
||||
"$ISSUER" "$HOST_ID" --out-dir "$OUT_ROOT/$HOST_ID" "${EXTRA[@]}"
|
||||
Loading…
Add table
Reference in a new issue