183 lines
6.6 KiB
Python
183 lines
6.6 KiB
Python
|
|
#!/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()
|