267 lines
9.2 KiB
Python
Executable File
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())
|