41499e069d
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.
238 lines
9.0 KiB
Python
Executable File
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)
|