Files

267 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Secrets-issuance HTTP service.
Lives on LXC 105 alongside the MCP server. Mesh-bound (nftables restricts to
Netbird + Tailscale subnets). Identifies callers by source mesh IP and returns
the per-client age private key.
State:
/var/lib/secrets-issuance/keys/<hostname>.key (private, 0600 root)
/var/lib/secrets-issuance/keys/<hostname>.pub (public)
/var/lib/secrets-issuance/denylist.txt (one hostname per line)
Inventory lookup:
/opt/homelab-context/inventory.yaml — the issuance server is itself a
homelab-context client, so it sees inventory updates within 5 min.
Endpoints:
POST /issue body: {"hostname": "..."} -> raw age private key
POST /revoke body: {"hostname": "..."} -> shred key + add to denylist
(called by 'homelab client remove')
GET /health
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import secrets as sysrandom
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import yaml
BIND_HOST = "0.0.0.0"
BIND_PORT = 9820
CONTEXT_DIR = Path(os.environ.get("HOMELAB_CONTEXT_DIR", "/opt/homelab-context"))
STATE_DIR = Path(os.environ.get("SECRETS_ISSUANCE_STATE", "/var/lib/secrets-issuance"))
KEYS_DIR = STATE_DIR / "keys"
DENYLIST = STATE_DIR / "denylist.txt"
ADMIN_TOKEN_PATH = Path(os.environ.get("SECRETS_ISSUANCE_ADMIN_TOKEN",
"/etc/secrets-issuance/admin-token"))
MESH_SUBNETS = [
ipaddress.ip_network(s.strip())
for s in os.environ.get("MESH_SUBNETS", "100.64.0.0/10").split(",")
if s.strip()
]
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("secrets-issuance")
def load_inventory() -> dict:
return yaml.safe_load((CONTEXT_DIR / "inventory.yaml").read_text())
def known_hosts() -> dict[str, dict]:
return load_inventory().get("hosts", {})
def host_by_mesh_ip(ip: str) -> str | None:
"""Resolve a mesh source IP to a hostname per inventory."""
for name, entry in known_hosts().items():
mesh = entry.get("mesh", {})
for proto in ("netbird", "tailscale"):
p = mesh.get(proto, {})
if not isinstance(p, dict):
continue
if p.get("ip") == ip:
return name
return None
def in_mesh(ip: str) -> bool:
addr = ipaddress.ip_address(ip)
return any(addr in subnet for subnet in MESH_SUBNETS)
def is_denied(hostname: str) -> bool:
if not DENYLIST.exists():
return False
return hostname in {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
def deny(hostname: str) -> None:
DENYLIST.parent.mkdir(parents=True, exist_ok=True)
existing = set()
if DENYLIST.exists():
existing = {line.strip() for line in DENYLIST.read_text().splitlines() if line.strip()}
existing.add(hostname)
DENYLIST.write_text("\n".join(sorted(existing)) + "\n")
def generate_key(hostname: str) -> tuple[Path, str]:
"""Generate an age keypair for hostname; return (priv_path, pubkey)."""
KEYS_DIR.mkdir(parents=True, exist_ok=True)
priv = KEYS_DIR / f"{hostname}.key"
pub_path = KEYS_DIR / f"{hostname}.pub"
proc = subprocess.run(["age-keygen"], capture_output=True, text=True, check=True)
priv.write_text(proc.stdout)
priv.chmod(0o600)
# Extract the public key. age-keygen writes "# public key: ageXXX" line.
pubkey = ""
for line in proc.stdout.splitlines():
if line.startswith("# public key:"):
pubkey = line.split(":", 1)[1].strip()
if not pubkey:
priv.unlink(missing_ok=True)
raise RuntimeError("age-keygen did not emit a public key")
pub_path.write_text(pubkey + "\n")
log.info("generated new age key for %s (pubkey: %s)", hostname, pubkey)
return priv, pubkey
def existing_pubkey(hostname: str) -> str | None:
pub_path = KEYS_DIR / f"{hostname}.pub"
if pub_path.exists():
return pub_path.read_text().strip()
return None
def read_admin_token() -> str | None:
if ADMIN_TOKEN_PATH.exists():
return ADMIN_TOKEN_PATH.read_text().strip()
return None
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
def _reply(self, status: int, body: bytes | str = b"",
content_type: str = "text/plain") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
if isinstance(body, str):
body = body.encode()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _read_json(self) -> dict | None:
length = int(self.headers.get("Content-Length", "0"))
if not length:
return {}
body = self.rfile.read(length)
try:
return json.loads(body)
except json.JSONDecodeError:
return None
def do_GET(self) -> None:
if self.path == "/health":
self._reply(200, "ok")
return
self._reply(404, "not found")
def do_POST(self) -> None:
client_ip = self.client_address[0]
if not in_mesh(client_ip):
log.warning("rejecting non-mesh source %s", client_ip)
self._reply(403, "non-mesh source")
return
if self.path == "/issue":
self._handle_issue(client_ip)
elif self.path == "/revoke":
self._handle_revoke()
else:
self._reply(404, "not found")
def _handle_issue(self, client_ip: str) -> None:
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
# Cross-check: does inventory list this hostname, and does its mesh IP
# match the caller (if known)?
hosts = known_hosts()
if hostname not in hosts:
log.warning("unknown hostname in /issue: %s (from %s)", hostname, client_ip)
self._reply(403, f"unknown hostname: {hostname} (run 'homelab client add {hostname}' first)")
return
if is_denied(hostname):
log.warning("denied hostname %s tried to issue (from %s)", hostname, client_ip)
self._reply(403, "hostname on denylist (was removed); operator must clear before re-enrolling")
return
inv_ip = host_by_mesh_ip(client_ip)
if inv_ip is not None and inv_ip != hostname:
log.warning("source %s maps to inventory host %s but body claims %s",
client_ip, inv_ip, hostname)
self._reply(403, "source IP / hostname mismatch")
return
if inv_ip is None:
# Inventory has no IP for this hostname yet (first-bootstrap state).
# Accept the call but log it loudly so the operator backfills.
log.info("issuing for %s (source %s) — inventory has no mesh IP yet",
hostname, client_ip)
priv = KEYS_DIR / f"{hostname}.key"
existing_pub = existing_pubkey(hostname)
if priv.exists() and existing_pub:
# Re-issue: client lost its key but is still authorized. Return
# the existing key.
log.info("returning existing key for %s", hostname)
self._reply(200, priv.read_text())
return
# Fresh provisioning.
priv, pubkey = generate_key(hostname)
# Tell the operator (via response body comment) which pubkey to commit.
body = priv.read_text()
body += f"\n# operator: add this pubkey to inventory.yaml under hosts.{hostname}.age_pubkey:\n"
body += f"# {pubkey}\n"
self._reply(200, body)
def _handle_revoke(self) -> None:
token = self.headers.get("X-Admin-Token", "")
expected = read_admin_token()
if not expected or token != expected:
self._reply(403, "admin token required")
return
payload = self._read_json()
if payload is None:
self._reply(400, "bad json")
return
hostname = (payload.get("hostname") or "").strip()
if not hostname:
self._reply(400, "hostname required")
return
priv = KEYS_DIR / f"{hostname}.key"
pub = KEYS_DIR / f"{hostname}.pub"
for p in (priv, pub):
if p.exists():
# shred-then-unlink
subprocess.run(["shred", "-u", str(p)], check=False)
if p.exists():
p.unlink(missing_ok=True)
deny(hostname)
log.warning("revoked %s (shredded keys, added to denylist)", hostname)
self._reply(200, f"revoked {hostname}\n")
def main() -> int:
KEYS_DIR.mkdir(parents=True, exist_ok=True)
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
log.info("listening on %s:%d (context=%s, state=%s)",
BIND_HOST, BIND_PORT, CONTEXT_DIR, STATE_DIR)
server.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())