Files
box/bin/identity-provider.py
T
operator 41499e069d feat(identity): per-scope network identity plane (slices 1-5)
Fingerprint map + pure resolver (account umbrella / key-level scope
rule), live Warp provider on warp-* structures, broker lifecycle
(up/down/cycle/exec/routes/status/bind), wireguard+socks boilerplate
stubs, agent-manager bind integration. CLI carries emails and
fingerprints only; key bytes never appear. 41 committed tests.
2026-10-08 04:03:45 +00:00

238 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""identity-provider.py — Proxy provider implementations.
A provider owns one network-identity substrate behind a fixed
interface: provision / teardown / cycle / exec / routes / status.
All subprocesses go through an injectable run function (same seam as
box-fleet-tui gather_*), so command shapes are unit-testable and no
test touches netns, sudo, or /etc/netvm.
Security boundaries (from the repo's own scripts):
- Warp identities generate via netvm-new-identity.sh, which the user
explicitly authorized operators to run (see netvm-provision-node.sh
header). Generation installs a root-0600 conf and prints nothing.
- This code NEVER reads /etc/netvm and never prints key material.
Confs are consumed only by root tools (wg setconf inside netns).
- CLI-facing output carries emails, labels, and fingerprints only.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
BIN_DIR = REPO_ROOT / "bin"
RunFn = Callable[..., Tuple[int, str]]
LABEL_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,22}$")
def _run(cmd: List[str], timeout: int = 120) -> Tuple[int, str]:
"""Run cmd, capture output. Returns (returncode, combined_output)."""
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
except subprocess.TimeoutExpired:
return 124, "timed out after %ds: %s" % (timeout, " ".join(cmd))
except OSError as e:
return 127, str(e)
class ProviderError(RuntimeError):
"""A provider operation failed (message is safe to show)."""
def check_label(label: str) -> str:
"""Validate a netvm label. Returns it or raises ProviderError."""
if not LABEL_RE.match(label or ""):
raise ProviderError(
"invalid label %r: lowercase letters, digits, hyphens "
"(max 23 chars)" % (label,))
return label
class Provider:
"""Interface every proxy provider implements. Boilerplate subclasses
override these with real substrate calls; see WarpProvider."""
name = "base"
ready = False
def provision(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Create the network identity + bring it up. Idempotent."""
raise NotImplementedError
def teardown(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Bring the identity's network down (keeps the identity)."""
raise NotImplementedError
def cycle(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Rotate to a fresh identity (teardown + new identity + up)."""
raise NotImplementedError
def exec(self, label: str, cmd: List[str],
run: Optional[RunFn] = None) -> Tuple[int, str]:
"""Run cmd inside the identity's network. Returns (rc, output)."""
raise NotImplementedError
def routes(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Read-only route/tunnel status for the identity."""
raise NotImplementedError
def status(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Read-only liveness: conf present, netns up, egress IP."""
raise NotImplementedError
class WarpProvider(Provider):
"""Cloudflare Warp provider on the established warp-* structures.
Identity: /etc/netvm/<label>.conf via netvm-new-identity.sh
(operator-authorized). Network: warp-<label> netns via
netvm-node-up.sh / netvm-node-down.sh. Exec: netvm-exec.sh.
Scopes are NOT nodes: no chrome-box profile, no NODES.md entry.
"""
name = "warp"
ready = True
def _conf_exists(self, label: str, run: RunFn) -> bool:
rc, _ = run(["test", "-f", "/etc/netvm/%s.conf" % label],
timeout=10)
return rc == 0
def provision(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
run = run or _run
check_label(label)
steps = []
if not self._conf_exists(label, run):
rc, out = run(["sudo", "-n", str(BIN_DIR / "netvm-new-identity.sh"),
label], timeout=300)
if rc != 0:
raise ProviderError("warp identity failed for %s: %s"
% (label, out[-200:]))
steps.append("identity=new")
else:
steps.append("identity=exists")
rc, out = run(["sudo", "-n", str(BIN_DIR / "netvm-node-up.sh"),
label], timeout=300)
if rc != 0:
raise ProviderError("netns up failed for %s: %s"
% (label, out[-200:]))
steps.append("netns=up")
return {"ok": "true", "label": label, "netns": "warp-" + label,
"steps": ",".join(steps)}
def teardown(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
run = run or _run
check_label(label)
rc, out = run(["sudo", "-n", str(BIN_DIR / "netvm-node-down.sh"),
label], timeout=120)
if rc != 0:
raise ProviderError("netns down failed for %s: %s"
% (label, out[-200:]))
return {"ok": "true", "label": label, "netns": "warp-" + label}
def cycle(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
"""Fresh warp identity: down + remove conf + provision.
Conf removal needs root on /etc/netvm; when denied, the old
identity is left intact (netns down) and the operator gets the
exact human step instead of a half-rotated state.
"""
run = run or _run
check_label(label)
self.teardown(label, run=run)
rc, out = run(["sudo", "-n", "rm", "-f",
"/etc/netvm/%s.conf" % label], timeout=30)
if rc != 0:
raise ProviderError(
"rotation paused for %s: cannot remove old identity "
"(%s). Human: sudo rm /etc/netvm/%s.conf, then cycle "
"again." % (label, out[-120:], label))
return self.provision(label, run=run)
def exec(self, label: str, cmd: List[str],
run: Optional[RunFn] = None) -> Tuple[int, str]:
run = run or _run
check_label(label)
if not cmd:
raise ProviderError("exec needs a command")
return run([str(BIN_DIR / "netvm-exec.sh"), label, "--"] + cmd,
timeout=120)
def routes(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
run = run or _run
check_label(label)
netns = "warp-" + label
_, route_out = run(["sudo", "-n", "ip", "netns", "exec", netns,
"ip", "route"], timeout=30)
_, wg_out = run(["sudo", "-n", "ip", "netns", "exec", netns,
"wg", "show"], timeout=30)
return {"label": label, "netns": netns, "routes": route_out,
"wireguard": wg_out}
def status(self, label: str,
run: Optional[RunFn] = None) -> Dict[str, str]:
run = run or _run
check_label(label)
conf = self._conf_exists(label, run)
rc, out = run(["ip", "netns", "list"], timeout=10)
up = rc == 0 and ("warp-" + label) in out
egress = ""
if conf and up:
rc, eg = self.exec(label, ["curl", "-s", "--max-time", "8",
"https://api.ipify.org"], run=run)
egress = eg.strip().splitlines()[-1] if rc == 0 and eg.strip() \
else ""
return {"label": label, "conf": "yes" if conf else "no",
"netns": "up" if up else "down", "egress": egress or "n/a"}
class GenericWireGuardProvider(Provider):
"""BOILERPLATE: bring-your-own WireGuard confinement.
Intended structure: the operator supplies a wg conf out of band
(same root-0600 handling as Warp confs — never read here);
provision creates warp-<label> netns + veth/NAT exactly like
WarpProvider but consumes the supplied conf instead of a
Cloudflare-registered identity. Cycle swaps to the next supplied
conf. Implement when the first non-Cloudflare tunnel is needed.
"""
name = "wireguard"
class SocksProxyProvider(Provider):
"""BOILERPLATE: per-scope SOCKS5 forward, no netns.
Intended structure: provision opens a dedicated local forward
(ssh -D style) per scope label and records its port; exec runs
commands with ALL_PROXY scoped to that port instead of entering a
netns; cycle re-establishes the forward via a fresh egress.
Implement when a scope needs proxy semantics without tunnels.
"""
name = "socks"
if __name__ == "__main__":
print("identity-provider.py is a library (see identity-broker.py)")
sys.exit(2)