feat(cred): harden client onboarding with Instagram linking portal, age verification bypass, and fleet runbook
This commit is contained in:
Executable
+378
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cred-client.py — Agent API client & CLI for cred.muse-dev.online.
|
||||
|
||||
Provides programmatic and CLI access for agents and operators to:
|
||||
- initiate: start onboarding for a client email/node
|
||||
- submit-otp: submit verification code transiently
|
||||
- status: query onboarding & vitality state for a node
|
||||
- list: list fleet accounts, emails, and statuses
|
||||
|
||||
Authentication:
|
||||
- Machine identity signature via ~/.ssh/muse-health (machine bl)
|
||||
- Or Bearer token from CRED_TOKEN / OPERATOR_TOKEN environment variable.
|
||||
|
||||
Usage:
|
||||
cred-client.py initiate --node <node> --email <email> [--service muse] [--account-name <name>]
|
||||
cred-client.py submit-otp --node <node> --otp <code> [--email <email>]
|
||||
cred-client.py status --node <node> [--json]
|
||||
cred-client.py list [--json]
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NETVM_DIR = os.environ.get("NETVM_DIR", "/home/super/Projects/NetVM")
|
||||
KEY_DEFAULT = os.path.expanduser("~/.ssh/muse-health")
|
||||
CRED_API_DEFAULT = os.environ.get("CRED_API_URL", "https://cred.muse-dev.online/api/cred")
|
||||
|
||||
|
||||
def load_registry():
|
||||
path = os.path.join(NETVM_DIR, "bin", "netvm-registry.py")
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("netvm_registry", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_accounts():
|
||||
"""Parse ACCOUNTS.md into a structured list of accounts."""
|
||||
path = os.path.join(NETVM_DIR, "ACCOUNTS.md")
|
||||
accounts = []
|
||||
if not os.path.exists(path):
|
||||
return accounts
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line.startswith("|") or line.startswith("| agent") or line.startswith("|-------"):
|
||||
continue
|
||||
cols = [c.strip() for c in line.strip("|").split("|")]
|
||||
if len(cols) >= 9:
|
||||
accounts.append({
|
||||
"agent": cols[0],
|
||||
"node": cols[1],
|
||||
"profile": cols[2],
|
||||
"login_type": cols[3],
|
||||
"meta_label": cols[4],
|
||||
"email": cols[5],
|
||||
"phone_otp": cols[6],
|
||||
"instagram_linked": cols[7],
|
||||
"status": cols[8],
|
||||
"egress_ip": cols[9] if len(cols) > 9 else "",
|
||||
"cdp_port": cols[10] if len(cols) > 10 else "",
|
||||
"display_name": cols[11] if len(cols) > 11 else "",
|
||||
"notes": cols[12] if len(cols) > 12 else ""
|
||||
})
|
||||
return accounts
|
||||
|
||||
|
||||
def sign_payload(payload_bytes, key_path=KEY_DEFAULT, namespace="cred"):
|
||||
"""Sign payload using SSH ed25519 key (zero secret across wire)."""
|
||||
if not os.path.exists(key_path):
|
||||
return None
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
data_file = os.path.join(tmp, "data")
|
||||
with open(data_file, "wb") as f:
|
||||
f.write(payload_bytes)
|
||||
r = subprocess.run(
|
||||
["ssh-keygen", "-Y", "sign", "-f", key_path, "-n", namespace, data_file],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
with open(data_file + ".sig") as f:
|
||||
return f.read().strip()
|
||||
finally:
|
||||
subprocess.run(["rm", "-rf", tmp], capture_output=True)
|
||||
|
||||
|
||||
class CredClient:
|
||||
def __init__(self, api_url=CRED_API_DEFAULT, key_path=KEY_DEFAULT):
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.key_path = key_path
|
||||
self.token = os.environ.get("CRED_TOKEN") or os.environ.get("OPERATOR_TOKEN")
|
||||
|
||||
def run_local_driver(self, node, step, email, otp=None, account_name=None):
|
||||
"""Execute local onboard-driver.py inside the node's netns."""
|
||||
exec_script = os.path.join(NETVM_DIR, "bin", "netvm-exec.sh")
|
||||
driver_script = os.path.join(NETVM_DIR, "bin", "onboard-driver.py")
|
||||
cmd = [exec_script, node, "--", sys.executable, driver_script,
|
||||
"--node", node, "--service", "muse", "--id-type", "email", "--step", step]
|
||||
if account_name:
|
||||
cmd += ["--account-name", account_name]
|
||||
|
||||
input_data = email + "\n"
|
||||
if otp:
|
||||
input_data += str(otp) + "\n"
|
||||
|
||||
p = subprocess.run(cmd, input=input_data, capture_output=True, text=True, timeout=240)
|
||||
return p.returncode, p.stdout.strip(), p.stderr.strip()
|
||||
|
||||
def initiate(self, node, email, service="muse", account_name=None):
|
||||
"""Initiate client onboarding."""
|
||||
ret, stdout, stderr = self.run_local_driver(node, "initiate", email, account_name=account_name)
|
||||
if ret == 0:
|
||||
return {"status": "active", "node": node, "email": email, "message": "Already authenticated and session active."}
|
||||
elif ret == 2:
|
||||
return {"status": "awaiting_otp", "node": node, "email": email, "message": "OTP verification code sent. Awaiting input."}
|
||||
elif ret == 3:
|
||||
return {"status": "needs_human", "node": node, "email": email, "message": "Multiple accounts match identifier. Manual selection required."}
|
||||
else:
|
||||
return {"status": "error", "node": node, "email": email, "code": ret, "detail": stderr or stdout}
|
||||
|
||||
def submit_otp(self, node, otp, email=None, service="muse"):
|
||||
"""Submit transient verification code."""
|
||||
if not email:
|
||||
# Look up email from ACCOUNTS.md
|
||||
for acct in get_accounts():
|
||||
if acct["node"] == node and acct["email"] not in ("-", ""):
|
||||
email = acct["email"]
|
||||
break
|
||||
if not email:
|
||||
email = "unknown"
|
||||
|
||||
ret, stdout, stderr = self.run_local_driver(node, "submit", email, otp=otp)
|
||||
if ret == 0:
|
||||
return {"status": "active", "node": node, "email": email, "message": "Authentication successful. Chat session active."}
|
||||
elif ret == 3:
|
||||
return {"status": "needs_human", "node": node, "email": email, "message": "Multiple accounts match identifier."}
|
||||
else:
|
||||
return {"status": "error", "node": node, "email": email, "code": ret, "detail": stderr or stdout}
|
||||
|
||||
def status(self, node):
|
||||
"""Check status of a node."""
|
||||
accounts = get_accounts()
|
||||
target = next((a for a in accounts if a["node"] == node), None)
|
||||
port = None
|
||||
reg = load_registry()
|
||||
if reg:
|
||||
port = reg.port_for(node)
|
||||
|
||||
# Vitality check
|
||||
alive = False
|
||||
detail = ""
|
||||
if port:
|
||||
try:
|
||||
checker = os.path.join(NETVM_DIR, "bin", "accounts-health.py")
|
||||
cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, checker, str(port)]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
data = json.loads(r.stdout.strip().splitlines()[-1])
|
||||
alive = data.get("session_alive", False)
|
||||
detail = data.get("detail", "")
|
||||
except Exception as e:
|
||||
detail = str(e)
|
||||
|
||||
return {
|
||||
"node": node,
|
||||
"status": target["status"] if target else "unregistered",
|
||||
"email": target["email"] if target else "-",
|
||||
"cdp_port": port,
|
||||
"session_alive": alive,
|
||||
"detail": detail
|
||||
}
|
||||
|
||||
def list_all(self):
|
||||
"""List all accounts and live statuses."""
|
||||
res = []
|
||||
for a in get_accounts():
|
||||
st = self.status(a["node"])
|
||||
res.append(st)
|
||||
return res
|
||||
|
||||
|
||||
def notify_operator(self, subject, body, to_email="defnotabotnet@gmail.com"):
|
||||
"""Send notification to operator via local MTA (msmtp or mail)."""
|
||||
sent = False
|
||||
err = None
|
||||
# Try msmtp first
|
||||
try:
|
||||
p = subprocess.Popen(["msmtp", to_email], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
msg = f"Subject: {subject}\nTo: {to_email}\nFrom: NetVM Automation <root@bl>\n\n{body}\n"
|
||||
stdout, stderr = p.communicate(input=msg)
|
||||
if p.returncode == 0:
|
||||
sent = True
|
||||
else:
|
||||
err = stderr.strip() or stdout.strip()
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
|
||||
if not sent:
|
||||
# Fallback to mail / s-nail
|
||||
try:
|
||||
p = subprocess.Popen(["mail", "-s", subject, to_email], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = p.communicate(input=body)
|
||||
if p.returncode == 0:
|
||||
sent = True
|
||||
else:
|
||||
err = stderr.strip() or stdout.strip()
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
|
||||
return {"sent": sent, "recipient": to_email, "error": err if not sent else None}
|
||||
|
||||
def link_instagram(self, node, notify=False):
|
||||
"""Fetch the Meta Accounts Center OAuth URL and provide one-tap Tailscale link."""
|
||||
portal_script = os.path.join(NETVM_DIR, "bin", "tailscale-verify-portal.py")
|
||||
ts_ip = "100.123.153.75"
|
||||
ts_dns = "bl.tailfb5960.ts.net"
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("portal", portal_script)
|
||||
portal_mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(portal_mod)
|
||||
ts_ip = portal_mod.get_tailscale_ip()
|
||||
ts_dns = portal_mod.get_tailscale_dns()
|
||||
ig_data = portal_mod.fetch_node_ig_link(node)
|
||||
except Exception as e:
|
||||
ig_data = {"error": str(e)}
|
||||
|
||||
direct_url = ig_data.get("url") if isinstance(ig_data, dict) else None
|
||||
portal_url = f"http://{ts_dns}:8765/verify/{node}"
|
||||
portal_ip_url = f"http://{ts_ip}:8765/verify/{node}"
|
||||
|
||||
email_result = None
|
||||
if notify and direct_url:
|
||||
subject = f"[NetVM Action Required] Link Instagram for Node '{node}'"
|
||||
body = (
|
||||
f"Node '{node}' is waiting at the Muse age verification gate.\n\n"
|
||||
f"Tap the Tailscale portal link from your device to approve:\n"
|
||||
f" {portal_url}\n"
|
||||
f" (or {portal_ip_url})\n\n"
|
||||
f"Direct OAuth URL:\n"
|
||||
f" {direct_url}\n\n"
|
||||
f"After approving in Instagram, NetVM will automatically transition node '{node}' to active."
|
||||
)
|
||||
email_result = self.notify_operator(subject, body)
|
||||
|
||||
return {
|
||||
"node": node,
|
||||
"status": "needs_verification",
|
||||
"portal_url": portal_url,
|
||||
"portal_ip_url": portal_ip_url,
|
||||
"direct_oauth_url": direct_url,
|
||||
"error": ig_data.get("error") if isinstance(ig_data, dict) else None,
|
||||
"email_notified": email_result.get("sent") if email_result else False
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--json", action="store_true", help="Output JSON response")
|
||||
|
||||
p = argparse.ArgumentParser(description="cred-client: Agent API client for cred onboarding", parents=[common])
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
# initiate
|
||||
p_init = sub.add_parser("initiate", parents=[common], help="Initiate onboarding flow for a node")
|
||||
p_init.add_argument("--node", required=True, help="Node label (e.g. opm, pip, client1)")
|
||||
p_init.add_argument("--email", required=True, help="Client login email")
|
||||
p_init.add_argument("--service", default="muse", help="Service name (default: muse)")
|
||||
p_init.add_argument("--account-name", default=None, help="Display name hint for multi-account selector")
|
||||
|
||||
# submit-otp
|
||||
p_otp = sub.add_parser("submit-otp", parents=[common], help="Submit transient OTP verification code")
|
||||
p_otp.add_argument("--node", required=True, help="Node label")
|
||||
p_otp.add_argument("--otp", required=True, help="6-digit verification code")
|
||||
p_otp.add_argument("--email", default=None, help="Client email (optional, auto-detected from registry)")
|
||||
|
||||
# status
|
||||
p_stat = sub.add_parser("status", parents=[common], help="Query onboarding and session status for a node")
|
||||
p_stat.add_argument("--node", required=True, help="Node label")
|
||||
|
||||
# link-instagram
|
||||
p_link = sub.add_parser("link-instagram", parents=[common], help="Generate Tailscale one-tap portal & OAuth link for age verification")
|
||||
p_link.add_argument("--node", required=True, help="Node label")
|
||||
p_link.add_argument("--notify", action="store_true", help="Send email alert to operator via local MTA")
|
||||
|
||||
# list
|
||||
sub.add_parser("list", parents=[common], help="List all registered nodes and vitality statuses")
|
||||
|
||||
args = p.parse_args()
|
||||
client = CredClient()
|
||||
|
||||
if args.command == "link-instagram":
|
||||
res = client.link_instagram(args.node, notify=args.notify)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
else:
|
||||
print(f"\n=== INSTAGRAM LINKING: {args.node} ===")
|
||||
if res.get("error"):
|
||||
print(f"Error: {res['error']}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Tailscale Portal: {res['portal_url']}")
|
||||
print(f"Direct IP Portal: {res['portal_ip_url']}")
|
||||
print(f"OAuth URL: {res['direct_oauth_url'][:80]}...")
|
||||
if args.notify:
|
||||
print(f"Email Notified: {'YES' if res['email_notified'] else 'FAILED'}")
|
||||
print("\nTap either Tailscale link from your phone/browser to complete Meta age verification.")
|
||||
sys.exit(0)
|
||||
|
||||
if args.command == "initiate":
|
||||
res = client.initiate(args.node, args.email, service=args.service, account_name=args.account_name)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
else:
|
||||
st = res.get("status")
|
||||
if st == "awaiting_otp":
|
||||
print(f"[APPROVAL_NEEDED] Code sent to [redacted] for node '{args.node}'.")
|
||||
print(f"Submit OTP with: super cred submit-otp --node {args.node} --otp <code>")
|
||||
sys.exit(2)
|
||||
elif st == "active":
|
||||
print(f"[SUCCESS] Node '{args.node}' is already authenticated and active.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"[{st.upper()}] {res.get('message') or res.get('detail')}")
|
||||
sys.exit(res.get("code", 1))
|
||||
|
||||
elif args.command == "submit-otp":
|
||||
res = client.submit_otp(args.node, args.otp, email=args.email)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
else:
|
||||
st = res.get("status")
|
||||
if st == "active":
|
||||
print(f"[SUCCESS] Node '{args.node}' successfully signed in!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"[{st.upper()}] {res.get('message') or res.get('detail')}")
|
||||
sys.exit(res.get("code", 1))
|
||||
|
||||
elif args.command == "status":
|
||||
res = client.status(args.node)
|
||||
if args.json:
|
||||
print(json.dumps(res, indent=2))
|
||||
else:
|
||||
print(f"Node: {res['node']}")
|
||||
print(f"Email: {res['email']}")
|
||||
print(f"Status: {res['status']}")
|
||||
print(f"CDP Port: {res['cdp_port'] or '-'}")
|
||||
print(f"Session Alive: {'YES' if res['session_alive'] else 'NO'}")
|
||||
if res["detail"]:
|
||||
print(f"Detail: {res['detail']}")
|
||||
|
||||
elif args.command == "list":
|
||||
items = client.list_all()
|
||||
if args.json:
|
||||
print(json.dumps(items, indent=2))
|
||||
else:
|
||||
print(f"{'NODE':<10} {'STATUS':<14} {'CDP':<6} {'ALIVE':<7} {'EMAIL'}")
|
||||
print("-" * 65)
|
||||
for it in items:
|
||||
alive_str = "YES" if it["session_alive"] else "NO"
|
||||
print(f"{it['node']:<10} {it['status']:<14} {str(it['cdp_port'] or '-'):<6} {alive_str:<7} {it['email']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user