#!/usr/bin/env python3 """onboard-driver.py — bl-side OTP onboarding driver. Runs INSIDE the node's netns (via netvm-exec.sh). Reads the identifier (line 1) and OTP code (line 2, submit step only) from stdin — never argv. onboard-driver.py --node muse --service muse --id-type email --step initiate [--dry-run] onboard-driver.py --node muse --service muse --id-type email --step submit Exit codes: 0 = step done, 2 = APPROVAL_NEEDED (code sent, awaiting OTP), 3 = NEEDS_HUMAN (multi-account selection needs a person), 4 = NEEDS_SIGNUP (unregistered client email — client must sign up first), 1 = failed. The identifier/code are passed to the local signin script as argv (transient, same trust domain — bl is operator infrastructure); they never cross a network boundary except inside the already-encrypted VM->bl SSH stdin pipe. Part of the cred onboarding module (front-door repo, docs/CRED-MODULE.md). """ import argparse import datetime import importlib.util import json import subprocess import sys import urllib.request SIGNIN = "/home/super/Projects/NetVM/bin/muse-signin.py" def _scrub(text, *secrets): """Redact secret values from captured output before passthrough.""" for s in secrets: if s and len(s) >= 4: text = text.replace(s, "[redacted]") return text def _mask_email(identifier): local, _, domain = identifier.partition("@") return (local[:1] + "***@" + domain) if domain else "***" def _load_registry(): path = "/home/super/Projects/NetVM/bin/netvm-registry.py" spec = importlib.util.spec_from_file_location("netvm_registry", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def cdp_ok(port): try: ts = json.load(urllib.request.urlopen( "http://127.0.0.1:%s/json/list" % port, timeout=5)) return any(t.get("type") == "page" for t in ts) except Exception: return False def main(): p = argparse.ArgumentParser() p.add_argument("--node", required=True) p.add_argument("--service", required=True) p.add_argument("--id-type", required=True) p.add_argument("--step", required=True, choices=["initiate", "submit"]) p.add_argument("--dry-run", action="store_true") p.add_argument("--account-name", default=None, help="display-name hint for multi-account selection") args = p.parse_args() if args.service != "muse" or args.id_type != "email": print("ERROR: unsupported service/id_type " "(muse+email only for now)", file=sys.stderr) return 1 port = _load_registry().port_for(args.node) if not port: print("ERROR: unknown node '%s' (not in NODES.md registry)" % args.node, file=sys.stderr) return 1 if args.dry_run: # Walk the chain without sending anything: netns + CDP + page. if cdp_ok(port): print("dry-run ok: node=%s cdp=%s reachable, page present" % (args.node, port)) return 0 print("ERROR: CDP unreachable on %s" % port, file=sys.stderr) return 1 lines = sys.stdin.read().splitlines() identifier = lines[0].strip() if lines else "" code = lines[1].strip() if len(lines) > 1 else "" if not identifier: print("ERROR: no identifier on stdin", file=sys.stderr) return 1 cmd = [sys.executable, SIGNIN, "--node", args.node, "--email", identifier] if args.account_name: cmd += ["--account-name", args.account_name] if args.step == "submit": if not code: print("ERROR: no code on stdin", file=sys.stderr) return 1 cmd += ["--otp", code] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=220) except subprocess.TimeoutExpired: # NB: TimeoutExpired str() includes the argv (with secrets) - # never let it reach stderr uncaught. print("ERROR: signin step timed out", file=sys.stderr) return 1 # Propagate the signin script's contract: # 0 = done, 2 = OTP prompt reached, 3 = NEEDS_HUMAN, 4 = NEEDS_SIGNUP. # Scrub: the signin script echoes the identifier/code in progress # output; redact before passthrough (server scrubs too, defense # in depth). sys.stdout.write(_scrub(r.stdout, identifier, code)) sys.stderr.write(_scrub(r.stderr, identifier, code)) if r.returncode == 4: log_entry = { "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), "type": "onboarding_needs_signup", "node": args.node, "service": args.service, "email_masked": _mask_email(identifier), "status": "needs_signup", "action_required": "ask_client_to_sign_up", "signup_url": "https://muse.ai" } try: with open("/home/super/Projects/NetVM/job-log.jsonl", "a") as f: f.write(json.dumps(log_entry) + "\n") except Exception: pass return r.returncode if __name__ == "__main__": sys.exit(main())