feat(cred): harden client onboarding with Instagram linking portal, age verification bypass, and fleet runbook
This commit is contained in:
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tailscale-verify-portal.py: Tailscale verification portal for Human-in-the-Loop age verification.
|
||||
|
||||
Serves on Tailscale IP (100.123.153.75:8765) and/or localhost.
|
||||
Endpoints:
|
||||
GET /status - JSON status of nodes awaiting verification
|
||||
GET /verify/<node> - Generates fresh Instagram OAuth linking URL from the node's browser and 302 redirects
|
||||
GET /check/<node> - Polls node to see if age gate has cleared into active chat session
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
PORT = 8765
|
||||
BIND_HOST = "0.0.0.0"
|
||||
NETVM_DIR = "/home/super/Projects/NetVM"
|
||||
|
||||
def get_tailscale_ip():
|
||||
try:
|
||||
out = subprocess.check_output(["tailscale", "ip", "-4"], text=True).strip().splitlines()
|
||||
for line in out:
|
||||
line = line.strip()
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+$", line):
|
||||
return line
|
||||
except Exception:
|
||||
pass
|
||||
return "100.123.153.75"
|
||||
|
||||
def get_tailscale_dns():
|
||||
try:
|
||||
out = subprocess.check_output(["tailscale", "status", "--json"], text=True)
|
||||
data = json.loads(out)
|
||||
self_dns = data.get("Self", {}).get("DNSName")
|
||||
if self_dns:
|
||||
return self_dns.rstrip(".")
|
||||
except Exception:
|
||||
pass
|
||||
return "bl.tailfb5960.ts.net"
|
||||
|
||||
def fetch_node_ig_link(node):
|
||||
"""Query CDP within node netns to get fresh Meta Accounts Center OAuth URL."""
|
||||
script = """
|
||||
import json, websocket, sys, urllib.request
|
||||
|
||||
try:
|
||||
tabs = json.loads(urllib.request.urlopen("http://127.0.0.1:9450/json").read())
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"CDP unreachable: {e}"}))
|
||||
sys.exit(0)
|
||||
|
||||
muse_tab = next((t for t in tabs if "muse.ai" in t.get("url", "") and t.get("type") == "page"), None)
|
||||
if not muse_tab:
|
||||
print(json.dumps({"error": "No muse.ai tab open"}))
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
ws = websocket.create_connection(muse_tab["webSocketDebuggerUrl"], timeout=5)
|
||||
expr = '''(async()=>{
|
||||
try {
|
||||
const r = await fetch('/api/hatch/age-confirmation/linking-web-auth?account_type=instagram', {
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
return await r.json();
|
||||
} catch(e) { return {error: String(e)}; }
|
||||
})()'''
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "awaitPromise": True, "returnByValue": True}}))
|
||||
while True:
|
||||
msg = json.loads(ws.recv())
|
||||
if msg.get("id") == 1:
|
||||
val = msg.get("result", {}).get("result", {}).get("value", {})
|
||||
print(json.dumps(val))
|
||||
break
|
||||
ws.close()
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": f"CDP evaluate failed: {e}"}))
|
||||
"""
|
||||
cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, "-c", script]
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
lines = res.stdout.strip().splitlines()
|
||||
if lines:
|
||||
data = json.loads(lines[-1])
|
||||
return data
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"error": "No response from node"}
|
||||
|
||||
def check_node_cleared(node):
|
||||
"""Check if node has transitioned past access/verification into active chat."""
|
||||
checker = os.path.join(NETVM_DIR, "bin", "accounts-health.py")
|
||||
# Lookup port from registry or default to 9450 for def
|
||||
port = 9450
|
||||
cmd = ["sudo", "-n", "ip", "netns", "exec", f"warp-{node}", sys.executable, checker, str(port)]
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
lines = res.stdout.strip().splitlines()
|
||||
if lines:
|
||||
data = json.loads(lines[-1])
|
||||
return data
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"error": "No response from checker"}
|
||||
|
||||
class VerifyHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
parts = [p for p in parsed.path.split("/") if p]
|
||||
|
||||
if not parts or parts[0] == "":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>NetVM Operator Portal</title>
|
||||
<style>body{font-family:system-ui,sans-serif;padding:2rem;background:#111;color:#eee;}a{color:#4ea8de;font-size:1.2rem;text-decoration:none;display:inline-block;margin:1rem 0;padding:0.75rem 1.5rem;background:#222;border-radius:8px;}a:hover{background:#333;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>NetVM Client Verification Portal</h1>
|
||||
<p>Nodes pending verification:</p>
|
||||
<p><a href="/verify/def">Tap to Link Instagram for Node 'def'</a></p>
|
||||
</body></html>"""
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
return
|
||||
|
||||
if parts[0] == "verify" and len(parts) >= 2:
|
||||
node = parts[1]
|
||||
data = fetch_node_ig_link(node)
|
||||
url = data.get("url")
|
||||
if url:
|
||||
# 302 redirect directly to Instagram OAuth login
|
||||
self.send_response(302)
|
||||
self.send_header("Location", url)
|
||||
self.end_headers()
|
||||
return
|
||||
else:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": "Failed to get Instagram link", "detail": data}).encode("utf-8"))
|
||||
return
|
||||
|
||||
if parts[0] == "check" and len(parts) >= 2:
|
||||
node = parts[1]
|
||||
st = check_node_cleared(node)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(st, indent=2).encode("utf-8"))
|
||||
return
|
||||
|
||||
if parts[0] == "status":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"portal": "online", "ts_ip": get_tailscale_ip(), "ts_dns": get_tailscale_dns()}).encode("utf-8"))
|
||||
return
|
||||
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Concise logging
|
||||
sys.stderr.write(f"[PORTAL] {self.address_string()} - {format % args}\n")
|
||||
|
||||
def run():
|
||||
with socketserver.TCPServer((BIND_HOST, PORT), VerifyHandler) as httpd:
|
||||
print(f"Tailscale Verification Portal serving on http://{get_tailscale_ip()}:{PORT}/")
|
||||
print(f"Tailscale DNS: http://{get_tailscale_dns()}:{PORT}/")
|
||||
sys.stdout.flush()
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user