**Phase 24 final verification — all green**
- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
270 lines
12 KiB
Python
270 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Materialise the dev dashboard's stored agent bundles in gitignored `data/certs/`.
|
|
|
|
.venv/bin/python scripts/dev-certs-export.py \
|
|
--url http://127.0.0.1:8090 --password "$DASH_ADMIN_PASSWORD" \
|
|
--names deskwork gateway --out-dir data/certs
|
|
|
|
Phase 24 task 01: the dev loop must stop producing `http://` agents, and the first step of
|
|
that is making the bundle a host needs *be there* after `./scripts/dev-server.sh restart`
|
|
instead of being a `curl` somebody remembers to run.
|
|
|
|
Two things this deliberately is not.
|
|
|
|
* **Not a second authority.** The CA is durable in the SQLite `meta` table (`certs.py`), and
|
|
this script only reads the route that serves it (`GET /api/certs/<name>/bundle`), which is
|
|
the request-time read (`certs.read_state`) and never the startup one (`ensure_certs`). So
|
|
"generate certs during dev" means *export what the first boot minted*: running this against
|
|
a dashboard changes no certificate, and a bundle it writes is byte-identical to the row.
|
|
* **Not a place a key is committed.** Everything it writes lands under the repo's gitignored
|
|
`data/` tree (the default), and a bundle carries that host's **private key**, so the file is
|
|
created by `os.open` with `0600` as its birth mode and re-chmodded in case it already
|
|
existed looser. Keep `--out-dir` inside `data/`.
|
|
|
|
Stdlib only (`urllib`), the way `install-agent.sh` reaches for `python3`: this runs on the dev
|
|
box against a loopback URL, and a helper that needs the app's own dependency set to sign in
|
|
would be a helper that cannot run before `uv sync`. The session token is read out of the
|
|
`Set-Cookie` of the public `POST /api/auth/login` and sent back as `Authorization: Bearer` —
|
|
the bearer carrier is the one the gate exempts from CSRF, and it never needs a cookie jar.
|
|
|
|
Exit codes: `0` every name resolved to a file or to the "nothing issued" note, `1` a host the
|
|
route refused for some other reason, `2` no sign-in (wrong password, no password, no server).
|
|
A sign-in failure is never silent, because the operator's next move is `install-agent.sh`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import email.message
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
#: The non-committed tree `TODO.md:3` asks for. `data/` is in `.gitignore`, so no key this
|
|
#: writes can be committed by a scoped `git add` of a phase's work.
|
|
DEFAULT_OUT_DIR = ROOT / "data" / "certs"
|
|
TIMEOUT_SECONDS = 10.0
|
|
|
|
try: # the package owns the cookie name; importing it is what stops that drifting
|
|
from dashboards.auth import SESSION_COOKIE
|
|
except ImportError: # a bare interpreter outside the project venv can still run this
|
|
SESSION_COOKIE = "dash_session"
|
|
|
|
#: `--names` accepts the shape `HOST_TARGETS` has (space or comma separated) so the caller can
|
|
#: hand over what it already parsed instead of this script re-parsing a config it must not
|
|
#: disagree with.
|
|
_SPLIT_RE = re.compile(r"[,\s]+")
|
|
|
|
|
|
class ExportError(Exception):
|
|
"""One sentence for the operator: what failed, and where to look next."""
|
|
|
|
|
|
def split_names(values: list[str] | None) -> list[str]:
|
|
"""The host names to export, from one space/comma-separated string or several."""
|
|
return [part for part in _SPLIT_RE.split(" ".join(values or []).strip()) if part]
|
|
|
|
|
|
def _base(url: str) -> str:
|
|
"""A base URL with no trailing slash, so joining a route path cannot double one."""
|
|
return url.strip().rstrip("/")
|
|
|
|
|
|
def _json(body: bytes) -> dict[str, object] | None:
|
|
try:
|
|
parsed = json.loads(body.decode("utf-8", "replace"))
|
|
except ValueError:
|
|
return None
|
|
return parsed if isinstance(parsed, dict) else None
|
|
|
|
|
|
def _detail(body: bytes) -> str:
|
|
"""The server's own sentence, or the first bytes of whatever it sent instead.
|
|
|
|
Every refusal this script can print is one the app writes for an operator
|
|
(`wrong password`, `too many failed sign-ins — try again in 120s`) and carries no
|
|
credential, which is the only reason echoing it is allowed at all (AGENTS.md invariant 6).
|
|
The response body of a *successful* sign-in never reaches the output.
|
|
"""
|
|
parsed = _json(body)
|
|
if parsed is not None and isinstance(parsed.get("detail"), str):
|
|
return str(parsed["detail"])
|
|
text = body.decode("utf-8", "replace").strip().replace("\n", " ")
|
|
return text[:160] or "no body"
|
|
|
|
|
|
def _request(
|
|
url: str,
|
|
*,
|
|
method: str = "GET",
|
|
body: bytes | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> tuple[int, bytes, email.message.Message | None]:
|
|
"""One HTTP call answered as `(status, body, headers)`.
|
|
|
|
urllib raises for every 4xx/5xx, and here a 404 **is** the answer: `{"configured": false}`
|
|
and `{"configured": true, "issued": false}` are two different facts about a host. So the
|
|
status comes back instead of an exception, and only a call that never reached the dashboard
|
|
raises.
|
|
"""
|
|
request = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
return response.status, response.read(), response.headers
|
|
except urllib.error.HTTPError as exc: # a refusal from the app: read it, do not raise
|
|
return exc.code, exc.read(), exc.headers
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
reason = getattr(exc, "reason", None) or exc
|
|
raise ExportError(f"no dashboard answered at {url}: {reason}") from exc
|
|
|
|
|
|
def _cookie(headers: email.message.Message | None, name: str) -> str:
|
|
"""The value of one `Set-Cookie` from a response, with no cookie jar."""
|
|
if headers is None:
|
|
return ""
|
|
for line in headers.get_all("set-cookie") or []:
|
|
for chunk in line.split(";"):
|
|
key, sep, value = chunk.strip().partition("=")
|
|
if sep and key == name:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def sign_in(base: str, password: str) -> str:
|
|
"""The session token for this password, or an :class:`ExportError`.
|
|
|
|
`/api/auth/login` is the one route an anonymous caller may POST (AGENTS.md invariant 12),
|
|
and the token it mints travels **only** in its `Set-Cookie` — never in a body field a page
|
|
could log — so reading it back out of the header is the whole job. A `429` says so with its
|
|
`Retry-After` in the sentence: a dev loop that trips its own throttle should wait, not
|
|
retype.
|
|
"""
|
|
status, body, headers = _request(
|
|
f"{base}/api/auth/login",
|
|
method="POST",
|
|
body=json.dumps({"password": password}).encode("utf-8"),
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
if status != 200:
|
|
raise ExportError(f"sign-in at {base} refused ({status}): {_detail(body)}")
|
|
token = _cookie(headers, SESSION_COOKIE)
|
|
if not token:
|
|
raise ExportError(f"sign-in at {base} answered 200 with no {SESSION_COOKIE} cookie")
|
|
return token
|
|
|
|
|
|
def fetch_bundle(base: str, name: str, token: str) -> bytes | str:
|
|
"""The bundle **bytes** for an issued host, or the one-line **note** for one with nothing.
|
|
|
|
The two 404 shapes are the invariant-3 pair and both mean "write no file": a host with no
|
|
certificate yet, and a name this dashboard does not dial at all. The second cannot happen
|
|
when the caller passes `HOST_TARGETS` names, so it is handled as a line rather than a
|
|
crash — a config typo must not stop the other hosts' bundles.
|
|
"""
|
|
route = f"/api/certs/{urllib.parse.quote(name, safe='')}/bundle"
|
|
status, body, _headers = _request(f"{base}{route}",
|
|
headers={"authorization": f"Bearer {token}"})
|
|
if status == 200 and body:
|
|
return body
|
|
payload = _json(body)
|
|
if status == 404 and payload is not None:
|
|
if payload.get("configured") is True:
|
|
return (f"{name}: no bundle issued yet — issue one on /certs "
|
|
f"(POST /api/certs/{name}/rotate mints it)")
|
|
return f"{name}: not in this dashboard's HOST_TARGETS — nothing to export"
|
|
raise ExportError(f"{name}: GET {route} answered {status}: {_detail(body)}")
|
|
|
|
|
|
def export_one(base: str, name: str, token: str, out_dir: Path) -> str:
|
|
"""One host's output line: where its bundle went, or why nothing was written.
|
|
|
|
Raises :class:`ExportError` for a host the route refused for a reason that is not "nothing
|
|
issued" — the caller counts those and exits non-zero, because a bundle nobody downloaded is
|
|
a host that stays on `http://`.
|
|
"""
|
|
result = fetch_bundle(base, name, token)
|
|
if isinstance(result, str):
|
|
return f" - {result}"
|
|
path, unchanged = write_bundle(out_dir, name, result)
|
|
return (f" ✓ {name}: {path} ({len(result)} bytes, mode 0600"
|
|
f"{', unchanged' if unchanged else ''})")
|
|
|
|
|
|
def write_bundle(out_dir: Path, name: str, data: bytes) -> tuple[Path, bool]:
|
|
"""Write one bundle at `0600`; returns (path, whether the bytes were already there).
|
|
|
|
`os.open` rather than `open`, so `0600` is the file's **birth** mode: there is no window in
|
|
which a private key exists on disk at the umask default. The `chmod` after covers the
|
|
already-existing file, for which `O_CREAT`'s mode argument is ignored.
|
|
"""
|
|
if not name or Path(name).name != name:
|
|
# Names come from HOST_TARGETS, which the route has already validated, but this is the
|
|
# one function that decides where a key lands on disk. Say it plainly and write nothing.
|
|
raise ExportError(f"{name!r} is not a usable host name — nothing written")
|
|
out_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = out_dir / f"{name}.bundle"
|
|
previous = path.read_bytes() if path.exists() else None
|
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(data)
|
|
os.chmod(path, 0o600)
|
|
return path, previous == data
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Export the dev dashboard's stored agent bundles to a non-committed "
|
|
"directory (one file per configured host that has an issued bundle).",
|
|
)
|
|
parser.add_argument("--url", required=True,
|
|
help="the dashboard's base URL, e.g. http://127.0.0.1:8090")
|
|
parser.add_argument("--password", default=os.environ.get("DASH_ADMIN_PASSWORD", ""),
|
|
help="the sign-in password (defaults to $DASH_ADMIN_PASSWORD, which "
|
|
"dev-server.sh already loads from .env.dev)")
|
|
parser.add_argument("--names", nargs="*", default=None, metavar="HOST",
|
|
help="the configured host names, space or comma separated (from "
|
|
"HOST_TARGETS — parsed by the caller, never re-parsed here)")
|
|
parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR),
|
|
help=f"where to write <name>.bundle (default: {DEFAULT_OUT_DIR}; keep "
|
|
"it inside the gitignored data/ tree)")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
names = split_names(args.names)
|
|
if not names:
|
|
print(" - no host names given (HOST_TARGETS unset?) — nothing to export")
|
|
return 0
|
|
if not args.password:
|
|
print(" ! no sign-in password: pass --password or set DASH_ADMIN_PASSWORD "
|
|
"(dev-server.sh loads it from .env.dev)", file=sys.stderr)
|
|
return 2
|
|
|
|
base = _base(args.url)
|
|
try:
|
|
token = sign_in(base, args.password)
|
|
except ExportError as exc:
|
|
print(f" ! {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
out_dir = Path(args.out_dir)
|
|
failed = 0
|
|
for name in names:
|
|
try:
|
|
print(export_one(base, name, token, out_dir))
|
|
except ExportError as exc:
|
|
print(f" ! {exc}", file=sys.stderr)
|
|
failed += 1
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|