Files
box/bin/tailscale-verify-portal.py
T

199 lines
7.2 KiB
Python
Executable File

#!/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 get_node_cdp_port(node):
nodes_file = os.path.join(NETVM_DIR, "NODES.md")
pinned = {"muse": 9410, "pip": 9420, "646": 9430, "opm": 9440, "def": 9450, "dev": 9460}
if node in pinned:
return pinned[node]
try:
with open(nodes_file) as f:
for line in f:
if not line.strip().startswith("|"): continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) >= 4 and cells[0] == node and cells[3].isdigit():
return int(cells[3])
except Exception:
pass
return 9450
def fetch_node_ig_link(node):
"""Query CDP within node netns to get fresh Meta Accounts Center OAuth URL."""
port = get_node_cdp_port(node)
script = f"""
import json, websocket, sys, urllib.request
try:
tabs = json.loads(urllib.request.urlopen("http://127.0.0.1:{port}/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")
port = get_node_cdp_port(node)
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()