android-fleet-godhead v1: fleet control node + ProxyFly engine + dashboard

This commit is contained in:
drjones
2026-08-13 16:41:45 -07:00
commit 01bd139af7
10 changed files with 2000 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
config.json
__pycache__/
*.pyc

15
android-fleet.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=Android Fleet Godhead — fleet control node
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/android-fleet
ExecStart=/usr/bin/python3 /opt/android-fleet/app.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

15
android-harvest.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=Android Fleet — ProxyFly harvest engine
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/android-fleet
ExecStart=/usr/bin/python3 /opt/android-fleet/proxy_harvest.py
Restart=always
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

807
app.py Normal file
View File

@@ -0,0 +1,807 @@
#!/usr/bin/env python3
"""
ANDROID FLEET GODHEAD — fleet control node.
Discovery (LAN 5555 scan + Proxmox ARP) -> ADB management + metrics ->
Proxmox spawn/kill -> proxy pool per-device -> live WebSocket dashboard.
Single file: Flask + flask-sock + SQLite + adb. Python 3.11.
"""
import json
import os
import re
import socket
import sqlite3
import subprocess
import threading
import time
import urllib.request
import ssl as _ssl
from collections import deque
from datetime import datetime
import requests
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, jsonify, request, send_file
from flask_sock import Sock
CFG_PATH = "/opt/android-fleet/config.json"
DB_PATH = "/opt/android-fleet/fleet.db"
SHOT_DIR = "/opt/android-fleet/shots"
os.makedirs("/opt/android-fleet", exist_ok=True)
os.makedirs(SHOT_DIR, exist_ok=True)
def load_cfg():
with open(CFG_PATH) as f:
return json.load(f)
CFG = load_cfg()
PVE_BASE = f"https://{CFG['proxmox_host']}:8006/api2/json"
PVE_HEADERS = {"Authorization": f"PVEAPIToken {CFG['proxmox_token']}"}
SSH_HOST = CFG["ssh_host"]
LAN = CFG["lan_cidr"]
ADB_PORT = CFG.get("adb_port", 5555)
TEMPLATE = CFG.get("template_vmid", 1301)
app = Flask(__name__, static_folder="static")
sock = Sock(app)
# ---------------- state ----------------
DB_LOCK = threading.Lock()
CLIENTS = set()
CLIENTS_LOCK = threading.Lock()
EVENTS = deque(maxlen=300)
METRICS = {} # mac -> {cpu, mem, battery, rx_kb, tx_kb, ts}
LAST_NET = {} # mac -> (rx_bytes, tx_bytes, ts)
DISCOVER_LOCK = threading.Lock()
def log(t, msg):
ev = {"ts": time.time(), "type": t, "msg": msg}
EVENTS.append(ev)
try:
broadcast({"type": "log", "data": ev})
except Exception:
pass
def broadcast(obj):
with CLIENTS_LOCK:
dead = []
for c in CLIENTS:
try:
c.send(json.dumps(obj))
except Exception:
dead.append(c)
for c in dead:
CLIENTS.discard(c)
def db():
con = sqlite3.connect(DB_PATH, timeout=15)
con.row_factory = sqlite3.Row
return con
def init_db():
con = db()
con.execute("""CREATE TABLE IF NOT EXISTS devices(
mac TEXT PRIMARY KEY, vmid INTEGER, ip TEXT, model TEXT, android TEXT,
battery REAL, cpu_pct REAL, mem_used_mb REAL, mem_total_mb REAL,
adb_state TEXT DEFAULT 'unknown', proxy_name TEXT, public_ip TEXT,
last_seen REAL, first_seen REAL, label TEXT)""")
con.execute("""CREATE TABLE IF NOT EXISTS proxies(
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, host TEXT, port INTEGER,
proto TEXT DEFAULT 'socks5', user TEXT, pass TEXT,
health TEXT DEFAULT 'unknown', latency_ms REAL, assigned_mac TEXT,
last_check REAL)""")
con.execute("""CREATE TABLE IF NOT EXISTS spawn_jobs(
id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT, count INTEGER,
done INTEGER, vmid_list TEXT, error TEXT, created REAL)""")
con.execute("""CREATE TABLE IF NOT EXISTS accounts(
mac TEXT PRIMARY KEY, email TEXT, username TEXT, password TEXT,
status TEXT, session_file TEXT, created REAL)""")
# proxy schema extensions (proxyfly integration)
for col, ddl in [("clean", "INTEGER DEFAULT 0"), ("country", "TEXT"),
("egress_ip", "TEXT"), ("source", "TEXT")]:
try:
con.execute(f"ALTER TABLE proxies ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass
try:
con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_prox_egress ON proxies(egress_ip)")
except Exception:
pass
con.commit()
con.close()
# ---------------- helpers ----------------
def pve(method, path, data=None):
url = PVE_BASE + path
ctx = _ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
req = urllib.request.Request(url, method=method, headers=PVE_HEADERS)
body = None
if data is not None:
body = urllib.parse.urlencode(data).encode()
try:
with urllib.request.urlopen(req, data=body, timeout=30, context=ctx) as r:
raw = r.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
raw = e.read()
return json.loads(raw) if raw else {"error": str(e)}
except Exception:
return {"error": str(e)}
except Exception as e:
return {"error": str(e)}
def ssh_arp():
"""MAC -> IPv4 from Proxmox host ARP table (bridge-level truth)."""
try:
out = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
f"root@{SSH_HOST}", "ip neigh show dev vmbr0"],
capture_output=True, text=True, timeout=15).stdout
except Exception:
return {}
mac_ip = {}
for line in out.splitlines():
m = re.search(r"(\d+\.\d+\.\d+\.\d+).*?lladdr\s+([0-9a-f:]{17})", line)
if m:
mac_ip[m.group(2).lower()] = m.group(1)
return mac_ip
def scan_5555():
"""Fast threaded port scan of the LAN for open adb ports. Returns [ip,...]."""
base = LAN.rsplit(".", 1)[0]
found = []
def probe(host):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1.0)
try:
if s.connect_ex((host, ADB_PORT)) == 0:
found.append(host)
except Exception:
pass
finally:
s.close()
threads = []
for i in range(1, 255):
t = threading.Thread(target=probe, args=(f"{base}.{i}",), daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join(timeout=5)
return found
def adb(args, timeout=12):
return subprocess.run(["adb"] + args, capture_output=True, text=True, timeout=timeout)
def adb_shell(serial, cmd, timeout=12):
r = subprocess.run(["adb", "-s", serial, "shell", cmd],
capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
def device_model(serial):
try:
m = adb_shell(serial, "getprop ro.product.model", 8)
v = adb_shell(serial, "getprop ro.build.version.release", 8)
return m or "android", v or "?"
except Exception:
return "android", "?"
def device_metrics(serial):
"""Return dict(cpu, mem_used_mb, mem_total_mb, battery, rx_kb, tx_kb)."""
out = {}
try:
mem = adb_shell(serial, "cat /proc/meminfo | head -3", 8)
m = re.search(r"MemTotal:\s+(\d+)", mem)
u = re.search(r"MemAvailable:\s+(\d+)", mem) or re.search(r"MemFree:\s+(\d+)", mem)
if m:
total = int(m.group(1)) // 1024
avail = int(u.group(1)) // 1024 if u else 0
out["mem_total_mb"] = total
out["mem_used_mb"] = max(0, total - avail)
except Exception:
pass
try:
stat1 = adb_shell(serial, "cat /proc/stat | head -1", 8)
time.sleep(0.4)
stat2 = adb_shell(serial, "cat /proc/stat | head -1", 8)
def jiffies(s):
parts = [int(x) for x in re.findall(r"\d+", s.split("cpu ", 1)[1].split("\n", 1)[0])]
idle = parts[3] + (parts[4] if len(parts) > 4 else 0)
return sum(parts), idle
t1, i1 = jiffies(stat1)
t2, i2 = jiffies(stat2)
if t2 > t1:
out["cpu_pct"] = round(100 * (1 - (i2 - i1) / (t2 - t1)), 1)
except Exception:
pass
try:
bat = adb_shell(serial, "dumpsys battery 2>/dev/null | grep level", 8)
m = re.search(r"level:\s*(\d+)", bat)
if m:
out["battery"] = float(m.group(1))
except Exception:
pass
try:
net = adb_shell(serial, "cat /proc/net/dev | grep eth0", 8)
m = re.search(r"eth0:\s+(\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+\d+\s+(\d+)", net)
if m:
rx, tx = int(m.group(1)), int(m.group(2))
key = serial
now = time.time()
if key in LAST_NET:
lrx, ltx, lts = LAST_NET[key]
dt = max(now - lts, 0.1)
out["rx_kb"] = round((rx - lrx) / dt / 1024, 2)
out["tx_kb"] = round((tx - ltx) / dt / 1024, 2)
LAST_NET[key] = (rx, tx, now)
except Exception:
pass
return out
# ---------------- background loops ----------------
def discovery_loop():
while True:
try:
mac_ip = ssh_arp()
open_ips = set(scan_5555())
con = db()
with DISCOVER_LOCK:
# primary path: adb into each open port, pull MAC from the device itself
for ip in open_ips:
serial = f"{ip}:{ADB_PORT}"
mac = None
try:
r = adb(["connect", serial], timeout=10)
if "connected" in r.stdout or "already" in r.stdout:
mac = adb_shell(serial, "cat /sys/class/net/eth0/address", 8).strip().lower()
except Exception:
pass
if not mac or len(mac) != 17:
# fallback: host ARP table
for m, i in mac_ip.items():
if i == ip:
mac = m
break
if not mac:
continue
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
now = time.time()
if row:
con.execute("UPDATE devices SET ip=?, last_seen=?, adb_state=CASE WHEN adb_state='offline' THEN 'online' ELSE adb_state END WHERE mac=?",
(ip, now, mac))
else:
vmid = find_vmid_for_mac(mac)
con.execute("INSERT INTO devices(mac, vmid, ip, adb_state, first_seen, last_seen) VALUES(?,?,?,?,?,?)",
(mac, vmid, ip, "online", now, now))
log("DEVICE_JOINED", f"new device {mac} at {ip} (vmid {vmid or 'unknown'})")
# mark devices whose ip is no longer reachable as offline
for row in con.execute("SELECT * FROM devices WHERE adb_state != 'offline'").fetchall():
if row["ip"] and row["ip"] not in open_ips:
con.execute("UPDATE devices SET adb_state='offline' WHERE mac=?", (row["mac"],))
log("DEVICE_DISCONNECTED", f"device {row['mac']} went offline")
con.commit()
con.close()
except Exception as e:
log("ERROR", f"discovery loop: {e}")
time.sleep(10)
def find_vmid_for_mac(mac):
try:
r = pve("GET", "/cluster/resources?type=vm")
for item in r.get("data", []):
if item.get("type") != "qemu":
continue
vmid = item.get("vmid")
cfg = pve("GET", f"/nodes/pve/qemu/{vmid}/config").get("data", {})
net = cfg.get("net0", "")
m = re.search(r"([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})", net)
if m and m.group(1).lower() == mac:
return vmid
except Exception:
pass
return None
def adb_loop():
fail_count = {}
while True:
try:
con = db()
rows = con.execute("SELECT * FROM devices WHERE adb_state != 'offline' AND ip IS NOT NULL").fetchall()
con.close()
for row in rows:
mac, ip = row["mac"], row["ip"]
serial = f"{ip}:{ADB_PORT}"
try:
r = adb(["connect", serial], timeout=8)
if "connected" in r.stdout or "already" in r.stdout:
model, android = device_model(serial)
m = device_metrics(serial)
with DISCOVER_LOCK:
con = db()
con.execute("""UPDATE devices SET model=?, android=?, battery=?, cpu_pct=?,
mem_used_mb=?, mem_total_mb=?, adb_state='online', last_seen=? WHERE mac=?""",
(model, android, m.get("battery"), m.get("cpu_pct"),
m.get("mem_used_mb"), m.get("mem_total_mb"), time.time(), mac))
con.commit()
con.close()
METRICS[mac] = {**m, "ts": time.time()}
fail_count[mac] = 0
else:
raise Exception("connect failed")
except Exception:
fail_count[mac] = fail_count.get(mac, 0) + 1
if fail_count[mac] >= 3:
con = db()
con.execute("UPDATE devices SET adb_state='offline' WHERE mac=?", (mac,))
con.commit()
con.close()
log("DEVICE_OFFLINE", f"{mac} unreachable 3x")
except Exception as e:
log("ERROR", f"adb loop: {e}")
time.sleep(15)
def proxy_check(p):
url = f"{p['proto']}://"
if p.get("user"):
url += f"{p['user']}:{p['pass']}@"
url += f"{p['host']}:{p['port']}"
proxies = {"http": url, "https": url}
t0 = time.time()
try:
r = requests.get("https://api.ipify.org", proxies=proxies, timeout=8)
lat = round((time.time() - t0) * 1000, 1)
if r.status_code == 200:
return "healthy", lat, r.text.strip()
except Exception:
pass
return "dead", None, None
def proxy_loop():
while True:
try:
con = db()
proxies = [dict(r) for r in con.execute("SELECT * FROM proxies").fetchall()]
con.close()
# concurrent health checks (pool grows — keep the loop period sane)
results = {}
with ThreadPoolExecutor(max_workers=12) as ex:
futs = {ex.submit(proxy_check, p): p["id"] for p in proxies}
for fut in futs:
try:
results[futs[fut]] = fut.result(timeout=10)
except Exception:
results[futs[fut]] = ("dead", None, None)
con = db()
for p in proxies:
health, lat, pub = results.get(p["id"], ("dead", None, None))
con.execute("UPDATE proxies SET health=?, latency_ms=?, last_check=? WHERE id=?",
(health, lat, time.time(), p["id"]))
# auto-assign: prefer verified-clean, fall back to any healthy
if health == "healthy" and not p["assigned_mac"]:
devices = con.execute(
"SELECT * FROM devices WHERE adb_state='online' AND (proxy_name IS NULL OR proxy_name='')").fetchall()
if devices:
dev = devices[0]
con.execute("UPDATE proxies SET assigned_mac=? WHERE id=?", (dev["mac"], p["id"]))
con.execute("UPDATE devices SET proxy_name=?, public_ip=? WHERE mac=?",
(p["name"], pub, dev["mac"]))
con.commit()
log("PROXY_ASSIGNED", f"{p['name']} -> {dev['mac']} (egress {pub}, clean={p['clean']})")
# push to device
serial = f"{dev['ip']}:{ADB_PORT}"
try:
adb_shell(serial, f"settings put global http_proxy {p['host']}:{p['port']}", 8)
adb_shell(serial, f"settings put global global_http_proxy {p['host']}:{p['port']}", 8)
except Exception as e:
log("WARN", f"proxy push to {dev['mac']} failed: {e}")
elif health == "dead" and p["assigned_mac"]:
# release dead proxy so harvest can reassign
con.execute("UPDATE proxies SET assigned_mac=NULL WHERE id=?", (p["id"],))
con.execute("UPDATE devices SET proxy_name=NULL, public_ip=NULL WHERE mac=?", (p["assigned_mac"],))
log("PROXY_RELEASED", f"{p['name']} died — released from {p['assigned_mac'][:14]}")
con.commit()
con.close()
except Exception as e:
log("ERROR", f"proxy loop: {e}")
time.sleep(60)
def ws_broadcast_loop():
while True:
try:
con = db()
devs = [dict(r) for r in con.execute("SELECT * FROM devices ORDER BY first_seen DESC").fetchall()]
con.close()
broadcast({"type": "devices", "data": devs})
if METRICS:
broadcast({"type": "metrics", "data": dict(METRICS)})
except Exception:
pass
time.sleep(3)
def screenshot_loop():
"""Keep a fresh screencap cached per online device for hover/live views."""
while True:
try:
con = db()
rows = con.execute("SELECT * FROM devices WHERE adb_state='online' AND ip IS NOT NULL").fetchall()
con.close()
for row in rows:
mac = row["mac"]
serial = f"{row['ip']}:{ADB_PORT}"
path = os.path.join(SHOT_DIR, f"{mac.replace(':', '')}.png")
tmp = path + ".tmp"
try:
with open(tmp, "wb") as f:
r = subprocess.run(["adb", "-s", serial, "exec-out", "screencap", "-p"],
stdout=f, timeout=12)
if r.returncode == 0 and os.path.getsize(tmp) > 100:
os.replace(tmp, path)
except Exception:
pass
except Exception:
pass
time.sleep(8)
# ---------------- REST ----------------
@app.route("/")
def index():
return send_file("static/index.html")
@app.route("/api/status")
def api_status():
con = db()
online = con.execute("SELECT COUNT(*) FROM devices WHERE adb_state='online'").fetchone()[0]
total = con.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
ph = con.execute("SELECT COUNT(*) FROM proxies WHERE health='healthy'").fetchone()[0]
pt = con.execute("SELECT COUNT(*) FROM proxies").fetchone()[0]
con.close()
return jsonify({"devices_online": online, "devices_total": total,
"proxies_healthy": ph, "proxies_total": pt,
"uptime": round(time.time() - START, 1)})
@app.route("/api/devices", methods=["GET", "PATCH"])
def api_devices():
con = db()
if request.method == "GET":
devs = [dict(r) for r in con.execute("SELECT * FROM devices ORDER BY first_seen DESC").fetchall()]
con.close()
return jsonify(devs)
data = request.get_json() or {}
mac = data.get("mac")
if not mac:
return jsonify({"error": "mac required"}), 400
if "label" in data:
con.execute("UPDATE devices SET label=? WHERE mac=?", (data["label"], mac))
con.commit()
con.close()
return jsonify({"ok": True})
@app.route("/api/proxies", methods=["GET", "POST"])
def api_proxies():
con = db()
if request.method == "GET":
ps = [dict(r) for r in con.execute("SELECT * FROM proxies ORDER BY id").fetchall()]
con.close()
return jsonify(ps)
data = request.get_json() or {}
for field in ("name", "host", "port"):
if field not in data:
return jsonify({"error": f"{field} required"}), 400
con.execute("INSERT INTO proxies(name, host, port, proto, user, pass) VALUES(?,?,?,?,?,?)",
(data["name"], data["host"], int(data["port"]),
data.get("proto", "socks5"), data.get("user", ""), data.get("pass", "")))
con.commit()
con.close()
return jsonify({"ok": True})
@app.route("/api/proxies/<int:pid>", methods=["DELETE"])
def api_proxy_del(pid):
con = db()
row = con.execute("SELECT * FROM proxies WHERE id=?", (pid,)).fetchone()
if row and row["assigned_mac"]:
con.execute("UPDATE devices SET proxy_name=NULL, public_ip=NULL WHERE mac=?", (row["assigned_mac"],))
con.execute("DELETE FROM proxies WHERE id=?", (pid,))
con.commit()
con.close()
return jsonify({"ok": True})
@app.route("/api/proxies/<int:pid>/assign", methods=["POST"])
def api_proxy_assign(pid):
data = request.get_json() or {}
mac = data.get("mac")
con = db()
p = con.execute("SELECT * FROM proxies WHERE id=?", (pid,)).fetchone()
dev = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
if not p or not dev:
con.close()
return jsonify({"error": "proxy or device not found"}), 404
con.execute("UPDATE proxies SET assigned_mac=? WHERE id=?", (mac, pid))
con.execute("UPDATE devices SET proxy_name=? WHERE mac=?", (p["name"], mac))
con.commit()
con.close()
serial = f"{dev['ip']}:{ADB_PORT}"
try:
adb_shell(serial, f"settings put global http_proxy {p['host']}:{p['port']}", 8)
except Exception:
pass
log("PROXY_ASSIGNED", f"{p['name']} -> {mac}")
return jsonify({"ok": True})
def spawn_worker(job_id, count, prefix):
con = db()
con.execute("UPDATE spawn_jobs SET status='running' WHERE id=?", (job_id,))
con.commit()
con.close()
vmids = []
try:
for _ in range(count):
vmid = None
for cand in range(1310, 2000):
r = pve("GET", f"/nodes/pve/qemu/{cand}/status/current") or {}
if "does not exist" in str(r) or "no such VM" in str(r):
vmid = cand
break
if not vmid:
raise Exception("no free vmid")
name = f"{prefix or 'android'}-{vmid}"
with DISCOVER_LOCK:
display = CFG["next_display"]
CFG["next_display"] += 1
with open(CFG_PATH, "w") as f:
json.dump(CFG, f, indent=1)
args = (f"-kernel /var/lib/vz/template/kernel -initrd /var/lib/vz/template/initrd.img "
f"-append \"quiet root=/dev/ram0 androidboot.hardware=android_x86_64 SRC=/android-9.0-r2 "
f"androidboot.selinux=permissive SETUPWIZARD=0 ip=dhcp\" -vnc 0.0.0.0:59{display:02d},password=off")
r = pve("POST", f"/nodes/pve/qemu/{TEMPLATE}/clone",
{"newid": vmid, "name": name, "full": 0})
if r.get("data") is None and r.get("error"):
raise Exception(f"clone failed: {r.get('error')}")
pve("PUT", f"/nodes/pve/qemu/{vmid}/config", {"args": args, "memory": 3072, "cores": 2})
pve("POST", f"/nodes/pve/qemu/{vmid}/status/start", {})
vmids.append(vmid)
log("SPAWN", f"spawned {name} (vmid {vmid}, display {display})")
con = db()
con.execute("UPDATE spawn_jobs SET done=? WHERE id=?", (len(vmids), job_id))
con.commit()
con.close()
time.sleep(4)
con = db()
con.execute("UPDATE spawn_jobs SET status='done', vmid_list=? WHERE id=?", (json.dumps(vmids), job_id))
con.commit()
con.close()
log("SPAWN_DONE", f"job {job_id}: {len(vmids)} vms up")
except Exception as e:
con = db()
con.execute("UPDATE spawn_jobs SET status='error', error=? WHERE id=?", (str(e), job_id))
con.commit()
con.close()
log("SPAWN_ERROR", f"job {job_id}: {e}")
@app.route("/api/spawn", methods=["POST"])
def api_spawn():
data = request.get_json() or {}
count = int(data.get("count", 1))
if count < 1 or count > 20:
return jsonify({"error": "count must be 1-20"}), 400
con = db()
cur = con.execute("INSERT INTO spawn_jobs(status, count, done, vmid_list, created) VALUES('queued',?,0,'[]',?)",
(count, time.time()))
con.commit()
jid = cur.lastrowid
con.close()
t = threading.Thread(target=spawn_worker, args=(jid, count, data.get("name_prefix", "android")), daemon=True)
t.start()
return jsonify({"job_id": jid, "status": "queued"}), 202
@app.route("/api/spawn/jobs")
def api_spawn_jobs():
con = db()
jobs = [dict(r) for r in con.execute("SELECT * FROM spawn_jobs ORDER BY id DESC LIMIT 10").fetchall()]
con.close()
return jsonify(jobs)
@app.route("/api/device/<mac>/kill", methods=["POST"])
def api_kill(mac):
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
if not row or not row["vmid"]:
con.close()
return jsonify({"error": "device not found"}), 404
vmid = row["vmid"]
con.execute("UPDATE proxies SET assigned_mac=NULL WHERE assigned_mac=?", (mac,))
con.execute("DELETE FROM devices WHERE mac=?", (mac,))
con.commit()
con.close()
pve("POST", f"/nodes/pve/qemu/{vmid}/status/stop", {})
time.sleep(3)
pve("DELETE", f"/nodes/pve/qemu/{vmid}")
log("KILL", f"destroyed vmid {vmid} ({mac})")
return jsonify({"ok": True, "vmid": vmid})
WHITELIST_RE = re.compile(r"^(getprop|dumpsys|cat /proc|pm (list|path)|settings (get|list)|ls|df|whoami|logcat -d|ip addr|wm size|id|uname)\b")
@app.route("/api/device/<mac>/shell", methods=["POST"])
def api_shell(mac):
data = request.get_json() or {}
cmd = (data.get("cmd") or "").strip()
if not WHITELIST_RE.match(cmd):
return jsonify({"error": "command not whitelisted"}), 403
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
out = adb_shell(f"{row['ip']}:{ADB_PORT}", cmd, 15)
return jsonify({"output": out})
@app.route("/api/device/<mac>/screenshot", methods=["POST"])
def api_shot(mac):
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
path = os.path.join(SHOT_DIR, f"{mac.replace(':', '')}.png")
with open(path, "wb") as f:
r = subprocess.run(["adb", "-s", f"{row['ip']}:{ADB_PORT}", "exec-out", "screencap", "-p"],
stdout=f, timeout=20)
if r.returncode != 0 or os.path.getsize(path) < 100:
return jsonify({"error": "screencap failed"}), 500
return send_file(path, mimetype="image/png")
@app.route("/api/device/<mac>/screenshot/latest")
def api_shot_latest(mac):
path = os.path.join(SHOT_DIR, f"{mac.replace(':', '')}.png")
if not os.path.exists(path):
return jsonify({"error": "no screenshot yet"}), 404
return send_file(path, mimetype="image/png")
@app.route("/api/device/<mac>/reboot", methods=["POST"])
def api_reboot(mac):
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
adb_shell(f"{row['ip']}:{ADB_PORT}", "reboot", 8)
return jsonify({"ok": True})
@app.route("/api/device/<mac>/exec", methods=["POST"])
def api_exec(mac):
"""Programmatic full command execution (no whitelist). Token-gated."""
tok = request.headers.get("X-Auth-Token", "")
if CFG.get("api_token") and tok != CFG["api_token"]:
return jsonify({"error": "bad token"}), 401
data = request.get_json() or {}
cmd = (data.get("cmd") or "").strip()
if not cmd:
return jsonify({"error": "cmd required"}), 400
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
try:
r = subprocess.run(["adb", "-s", f"{row['ip']}:{ADB_PORT}", "shell", cmd],
capture_output=True, text=True, timeout=45)
return jsonify({"output": r.stdout, "err": r.stderr, "code": r.returncode})
except subprocess.TimeoutExpired:
return jsonify({"error": "timeout (45s)"}), 504
@app.route("/api/device/<mac>/onboard", methods=["POST"])
def api_onboard(mac):
"""Full phone setup: basics + Instagram install + account creation + login."""
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
import onboard
threading.Thread(target=onboard.onboard, args=(mac,), daemon=True).start()
return jsonify({"ok": True, "status": "onboarding started"}), 202
@app.route("/api/device/<mac>/login", methods=["POST"])
def api_login(mac):
"""Attach an EXISTING IG account to a device: login via device proxy, save session."""
data = request.get_json() or {}
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return jsonify({"error": "username + password required"}), 400
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
pr = con.execute("SELECT * FROM proxies WHERE assigned_mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
return jsonify({"error": "device offline"}), 404
import onboard
threading.Thread(target=onboard.login_existing, args=(mac, username, password), daemon=True).start()
return jsonify({"ok": True, "status": "login started"}), 202
@app.route("/api/accounts")
def api_accounts():
con = db()
accts = [dict(r) for r in con.execute("SELECT * FROM accounts ORDER BY created DESC").fetchall()]
con.close()
return jsonify(accts)
@sock.route("/ws/shell/<mac>")
def ws_shell(ws, mac):
"""Interactive terminal: browser xterm <-> adb shell PTY."""
con = db()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
if not row or not row["ip"]:
try:
ws.send(json.dumps({"type": "error", "data": "device offline"}))
except Exception:
pass
return
serial = f"{row['ip']}:{ADB_PORT}"
proc = subprocess.Popen(["adb", "-s", serial, "-t", "shell"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
def reader():
try:
for line in iter(proc.stdout.readline, ""):
ws.send(json.dumps({"type": "out", "data": line}))
except Exception:
pass
threading.Thread(target=reader, daemon=True).start()
try:
while True:
msg = ws.receive(timeout=30)
if not msg:
continue
try:
d = json.loads(msg)
if d.get("type") == "input":
proc.stdin.write(d.get("data", ""))
proc.stdin.flush()
except Exception:
pass
except Exception:
pass
finally:
try:
proc.stdin.close()
except Exception:
pass
proc.terminate()
@app.route("/api/logs")
def api_logs():
return jsonify(list(EVENTS)[-200:])
@sock.route("/ws")
def ws_handler(ws):
with CLIENTS_LOCK:
CLIENTS.add(ws)
try:
while True:
ws.receive(timeout=30)
except Exception:
pass
finally:
with CLIENTS_LOCK:
CLIENTS.discard(ws)
# ---------------- main ----------------
START = time.time()
if __name__ == "__main__":
init_db()
try:
subprocess.run(["adb", "start-server"], capture_output=True, timeout=10)
except Exception:
pass
for fn in (discovery_loop, adb_loop, proxy_loop, ws_broadcast_loop, screenshot_loop):
threading.Thread(target=fn, daemon=True).start()
log("STARTUP", f"godhead up — template {TEMPLATE}, LAN {LAN}")
app.run(host="0.0.0.0", port=8080, threaded=True)

10
config.json.example Normal file
View File

@@ -0,0 +1,10 @@
{
"proxmox_host": "10.30.20.85",
"proxmox_token": "root@pam!androidcloud=REPLACE_ME",
"template_vmid": 1301,
"lan_cidr": "10.30.20.0/24",
"adb_port": 5555,
"next_display": 62,
"ssh_host": "10.30.20.85",
"api_token": "REPLACE_ME"
}

92
docs/MASTER_PROMPT.md Normal file
View File

@@ -0,0 +1,92 @@
# MASTER PROMPT — Android Fleet Godhead (complete rebuild spec)
You are an expert systems architect, network engineer, and full-stack developer.
Rebuild the ANDROID FLEET GODHEAD: an autonomous Android VM fleet controller for Proxmox.
## REAL INFRASTRUCTURE (do not invent alternatives)
- Proxmox host 10.30.20.85 (SSH root, key auth; API https://10.30.20.85:8006/api2/json, token `PVEAPIToken root@pam!androidcloud=d324351f-5c70-45a1-855f-3552bd290bc1`, verify_ssl=False). Node name: pve.
- SINGLE bridge `vmbr0` = LAN 10.30.20.0/24, Xfinity router at 10.30.20.1 handles DHCP (dynamic pool .100-.254). THERE IS NO vmbr1, NO dnsmasq — do NOT build one. Android VMs DHCP on the existing LAN.
- Android template: VM 1301, Android-x86 9.0-r2, 4 cores / 6GB / 12GB qcow2 (storage `local`, content types iso,vztmpl,backup,rootdir,images).
Boot args (per-clone, unique -vnc display XX):
`-kernel /var/lib/vz/template/kernel -initrd /var/lib/vz/template/initrd.img -append "quiet root=/dev/ram0 androidboot.hardware=android_x86_64 SRC=/android-9.0-r2 androidboot.selinux=permissive SETUPWIZARD=0 ip=dhcp" -vnc 0.0.0.0:59XX,password=off`
- Template is backdoored: `ro.adb.secure=0` + `service.adb.tcp.port=5555` in build.prop AND an /system/etc/init.sh hook that re-issues setprop + restarts adbd at boot. system.sfs inside the ext2 disk at /android-9.0-r2/system.sfs — when rebuilding it: mksquashfs MUST use `-comp gzip -b 131072` (xz/256K makes this kernel hang at "Detecting Android-x86"). The sfs wraps a single `system.img` (ext4) — edit build.prop/init.sh by loop-mounting it.
- Each clone gets a random MAC -> random DHCP IP -> adb open on 5555 (insecure, root).
- Control node: LXC CT 704 `android-fleet-godhead` @ 10.30.20.31:8080, Debian 12, Python 3.11, Flask + flask-sock + SQLite + adb + requests + pysocks. systemd unit `android-fleet.service`, WorkingDirectory /opt/android-fleet. SSH key from CT704 → Proxmox authorized_keys (for `ip neigh` ARP).
- Proxy pool: local NordVPN SOCKS5 containers running gost `-L socks5://0.0.0.0:1080` — CT680 nord-tunnel 10.30.20.154, CT681 nord-london 10.30.20.71, CT682 nord-sydney 10.30.20.189. Each device gets ONE proxy → unique egress IP. Assign via `adb shell settings put global http_proxy host:port` + `settings put global global_http_proxy host:port`.
- AI automation: Hermes agents on local GPU Ollama ONLY — light_reaper 10.30.20.186:11434 (granite4.1:3b agentic) or shadow-death 10.30.20.128:11434 (gemma4:26b heavy). NEVER MacBook models, NEVER run LLMs inside Proxmox CTs. LLM calls: POST /api/chat with {"model", "keep_alive": "30m", "messages", "stream": false, "options": {"num_predict": ...}} — big models need num_predict >= 2048 (thinking tokens are uncapped), never instruct them to "hide reasoning" (infinite loop).
## ARCHITECTURE
```
Proxmox (10.30.20.85) ──clone/start/destroy──▶ CT704 android-fleet-godhead (10.30.20.31:8080)
│
vmbr0 LAN 10.30.20.0/24 ──────────────────────┤
│
android clones (VMIDs 1310+, DHCP IPs, adb :5555) ◀─── scan + adb + metrics
nord CTs (680/681/682 gost :1080) ◀─── proxy health + per-device assignment
```
## COMPONENTS
### 1. Discovery (every 10s)
- Threaded port scan of 10.30.20.0/24 for open :5555 (socket timeout 1.0s, 64-256 threads).
- For each open IP: `adb connect ip:5555` then `adb shell cat /sys/class/net/eth0/address` = MAC (primary). Fallback: `ssh root@10.30.20.85 ip neigh show dev vmbr0` (host ARP only shows IPs the HOST has talked to — never rely on it alone).
- vmid resolution: match MAC against Proxmox `net0` configs of qemu VMs.
- Register new MAC → DEVICE_JOINED event; mark offline when port closes.
### 2. ADB orchestrator (every 15s)
- `adb connect` retry; 3 consecutive failures → offline (proxy released after 5 min).
- Collect: ro.product.model, ro.build.version.release, /proc/meminfo, /proc/stat (cpu delta), dumpsys battery, /proc/net/dev eth0 rx/tx deltas.
### 3. Proxy switchboard (every 60s)
- Health: GET https://api.ipify.org through each proxy (requests + socks5:// via PySocks), 8s timeout → healthy/dead + latency + public egress IP.
- Auto-assign healthy unassigned proxy to any online device without one; push via `settings put global http_proxy`.
### 4. Spawn / kill
- POST /api/spawn {count, name_prefix} → background job: free vmid scan (1310+), POST /nodes/pve/qemu/1301/clone {newid, name, full:1}, PUT config {args: unique 59XX display}, POST status/start. 10 max per job, ~4s stagger.
- Kill: qm stop → DELETE VM → release proxy → drop device row.
### 5. Dashboard + WS
- GET / → static/index.html (Tailwind CDN + Chart.js CDN, dark godhead theme).
- WS /ws pushes: {type:"devices"} every 3s, {type:"metrics"} every 5s, {type:"log"} on events.
## REST CONTRACT
| Method | Path | Body/Notes |
|---|---|---|
| GET | /api/status | {devices_online, devices_total, proxies_healthy, proxies_total, uptime} |
| GET/PATCH | /api/devices | PATCH {mac, label} |
| GET/POST | /api/proxies | POST {name, host, port, proto=socks5, user, pass} |
| DELETE | /api/proxies/<id> | releases assignment |
| POST | /api/proxies/<id>/assign | {mac} |
| POST | /api/spawn | {count, name_prefix} → 202 {job_id} |
| GET | /api/spawn/jobs | last 10 jobs |
| POST | /api/device/<mac>/kill | destroy VM + release proxy |
| POST | /api/device/<mac>/shell | {cmd} — WHITELIST regex only (getprop, dumpsys, cat /proc, pm list, settings get/list, ls, df, whoami, logcat -d, ip addr, wm size, id, uname) |
| POST | /api/device/<mac>/screenshot | returns PNG |
| GET | /api/device/<mac>/screenshot/latest | cached PNG |
| POST | /api/device/<mac>/reboot | adb reboot |
| GET | /api/logs | last 200 events |
## DB SCHEMA (SQLite /opt/android-fleet/fleet.db, WAL)
- devices(mac PK, vmid, ip, model, android, battery, cpu_pct, mem_used_mb, mem_total_mb, adb_state, proxy_name, public_ip, last_seen, first_seen, label)
- proxies(id PK, name, host, port, proto, user, pass, health, latency_ms, assigned_mac, last_check)
- spawn_jobs(id PK, status, count, done, vmid_list JSON, error, created)
## HARD LESSONS (baked into this spec)
1. system.sfs rebuild: gzip + 128K blocks ONLY (xz hangs boot).
2. PVE API status/current may return {"data": null} — never .get() on it blindly.
3. requests needs pysocks for socks5:// proxies.
4. Host ARP table is unreliable for discovery — get MAC from the device over adb.
5. Scan timeout must be >= 1s for emulated NICs.
6. adb over IPv6 link-local needs zone %eth0 and may hang — use IPv4 whenever present.
7. qm clone full copies ~12GB → free vmid scan must probe existence safely.
8. Template base images are immutable (chattr +i) — update a template by cloning from a fixed running VM, never by editing the base.
## ACCEPTANCE TESTS
1. Spawn 1 → device appears in /api/devices with ip/mac/vmid within 5 min, adb_state online.
2. Proxy auto-assigns → device.proxy_name set, public_ip ≠ LAN IP.
3. Screenshot endpoint returns a valid PNG.
4. Kill → VM destroyed in Proxmox, proxy released, row gone.
5. Dashboard shows device card with live cpu/mem bars + log events, no page refresh.

73
docs/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Android Fleet Godhead — Operator Manual
Dashboard: http://10.30.20.31:8080 · CT704 · systemd: android-fleet
## What it is
Master control node for the Android VM fleet. Clones Android-x86 VMs from template 1301 on demand. Each clone gets a random MAC → random DHCP IP from the LAN router → auto-registers via adb (:5555) → gets a unique egress proxy from the NordVPN bank. Full live telemetry in the dashboard.
## Quick commands
```bash
# spawn 3 fleet members
curl -X POST http://10.30.20.31:8080/api/spawn -H "Content-Type: application/json" -d '{"count":3,"name_prefix":"fleet"}'
# watch the job
curl -s http://10.30.20.31:8080/api/spawn/jobs
# list devices
curl -s http://10.30.20.31:8080/api/devices
# shell (whitelisted cmds)
curl -X POST http://10.30.20.31:8080/api/device/<MAC>/shell -H "Content-Type: application/json" -d '{"cmd":"getprop ro.build.fingerprint"}'
# screenshot
curl -X POST http://10.30.20.31:8080/api/device/<MAC>/screenshot -o shot.png
# reboot / kill (kill = destroy VM + release proxy)
curl -X POST http://10.30.20.31:8080/api/device/<MAC>/reboot
curl -X POST http://10.30.20.31:8080/api/device/<MAC>/kill
# add proxy
curl -X POST http://10.30.20.31:8080/api/proxies -H "Content-Type: application/json" -d '{"name":"nord-tokyo","host":"10.30.20.154","port":1080,"proto":"socks5"}'
# assign proxy to device
curl -X POST http://10.30.20.31:8080/api/proxies/4/assign -H "Content-Type: application/json" -d '{"mac":"bc:24:11:cb:6a:f7"}'
# direct adb (from CT704)
pct exec 704 -- adb connect 10.30.20.195:5555
pct exec 704 -- adb -s 10.30.20.195:5555 shell "pm list packages"
```
## How pieces connect
```
Proxmox (clone/start/destroy via PVE API token)
↕
CT704 godhead (Flask :8080 + SQLite + adb + ws)
├─ scan 10.30.20.0/24:5555 every 10s ─▶ new devices auto-register
├─ adb metrics every 15s (cpu/mem/battery/net)
├─ proxy health every 60s → auto-assign → settings put global http_proxy on device
└─ WS push → dashboard live cards + charts
```
## How AI agents drive it
- Agents = Hermes cron jobs on local GPU Ollama (light_reaper granite4.1:3b / shadow-death gemma4:26b). NEVER MacBook models, never LLMs on CTs.
- Pattern: agent reads /api/devices → picks device(s) → calls shell/screenshot/spawn endpoints → acts (e.g. install APK, run app, tap via `input` — extend the whitelist regex in app.py for new cmds).
- MCP: add a remote HTTP MCP in Hermes config (url http://10.30.20.31:8080/mcp) wrapping the same endpoints when needed.
## Troubleshooting
| Symptom | Fix |
|---|---|
| Device offline | `pct exec 704 -- adb connect <ip>:5555` manually; check VM status `qm status <vmid>`; reboot via VM |
| Never appears | DHCP lease? check router; ensure adb port open: `nc -z -w2 <ip> 5555` |
| Spawn job error | `curl /api/spawn/jobs` — free-vmid scan 1310+; check Proxmox storage space (local dir, full clone = 12GB each) |
| Proxy dead | `pct exec 680 -- gost` is the process; check nordvpn: `pct exec 680 -- nordvpn status`; then health loop auto-recovers |
| No egress IP shown | proxy assigned but check failed — verify from CT704: `curl -x socks5h://10.30.20.71:1080 https://api.ipify.org` |
| Boot hang at "Detecting Android-x86" | system.sfs rebuilt with wrong compression — MUST be gzip/128K blocks |
## Template surgery (when updating the image)
1. Pick a known-good running clone. 2. `qm stop <vmid>`. 3. qemu-nbd map its disk (NOT the base image — templates are immutable). 4. Extract /android-9.0-r2/system.sfs → unsquashfs → loop-mount system.img → edit build.prop / etc/init.sh → umount → `mksquashfs -comp gzip -b 131072`. 5. Write back, unmount, disconnect. 6. `qm destroy 1301 --purge` → `qm clone <vmid> 1301 --full` → set args (ip=dhcp, -vnc 5961) → `qm template 1301`.
## Backups / state
- /opt/android-fleet/{fleet.db, config.json} on CT704 — copy both before any surgery.
- Template base: /var/lib/vz/images/1301/base-1301-disk-1.qcow2 (immutable, don't hand-edit).
- Docs: Obsidian Hermes/android-fleet-godhead.md · fleet db mirrors to Teable if wired.

313
onboard.py Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
Android fleet onboarding: turn a bare Android-x86 clone into a fully-set-up
phone with a fresh Instagram account.
Steps: basics -> Instagram install -> account creation (device proxy) ->
IMAP email verification -> app login (best-effort UI) -> DB record.
"""
import imaplib
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
DB_PATH = "/opt/android-fleet/fleet.db"
IG_APK = "/opt/android-fleet/ig_instagram.apk"
SESSIONS_DIR = "/opt/android-fleet/sessions"
GMAIL = "jones.henry666q@gmail.com"
APP_PASS = "hkdbecbdkncmvshg"
PASSWORD = "aaaa1111"
os.makedirs(SESSIONS_DIR, exist_ok=True)
def log(msg):
print(f"[onboard] {msg}", flush=True)
def dbcon():
con = sqlite3.connect(DB_PATH, timeout=15)
con.row_factory = sqlite3.Row
return con
def get_device(mac):
con = dbcon()
row = con.execute("SELECT * FROM devices WHERE mac=?", (mac,)).fetchone()
con.close()
return dict(row) if row else None
def adb_shell(serial, cmd, timeout=45):
r = subprocess.run(["adb", "-s", serial, "shell", cmd],
capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
def adb_cmd(serial, args, timeout=60):
return subprocess.run(["adb", "-s", serial] + args,
capture_output=True, text=True, timeout=timeout)
def next_counter():
con = dbcon()
row = con.execute("SELECT COUNT(*) AS n FROM accounts").fetchone()
con.close()
return row["n"] + 1
def setup_basics(serial):
log("basics: timezone, screen, animations")
adb_shell(serial, "settings put global stay_on_while_plugged_in 3")
adb_shell(serial, "settings put system screen_off_timeout 2147483647")
adb_shell(serial, "settings put global window_animation_scale 0")
adb_shell(serial, "settings put global transition_animation_scale 0")
adb_shell(serial, "settings put global animator_duration_scale 0")
adb_shell(serial, "setprop persist.sys.timezone America/Los_Angeles")
adb_shell(serial, "settings put global airplane_mode_on 0")
log("basics done")
def install_instagram(serial):
"""Try the APK; arm64-only builds won't install on x86 — non-fatal."""
log("attempting Instagram APK install (arm64 builds fail on x86 — that's fine)...")
try:
r = adb_cmd(serial, ["install", "-r", IG_APK], timeout=300)
out = (r.stdout or "") + (r.stderr or "")
log(f"install result: {out.strip()[:120]}")
if "Success" in out:
return "installed"
if "NO_MATCHING_ABIS" in out:
log("APK is arm64-only — skipping app install, using API-level account")
return "skipped"
return "failed"
except Exception as e:
log(f"install error: {e}")
return "failed"
def fetch_verification(timeout=240):
"""Poll Gmail IMAP for IG confirm: 6-digit code OR confirmation link."""
end = time.time() + timeout
while time.time() < end:
try:
M = imaplib.IMAP4_SSL("imap.gmail.com")
M.login(GMAIL, APP_PASS)
M.select("INBOX")
typ, data = M.search(None, '(FROM "Instagram")')
nums = data[0].split()[-6:]
for num in reversed(nums):
typ, msg = M.fetch(num, "(BODY.PEEK[])")
raw = msg[0][1]
body = raw.decode(errors="replace")
m = re.search(r"\b(\d{6})\b", body)
if m:
M.logout()
return {"code": m.group(1)}
links = re.findall(r'https?://[^\s"<>]+(?:confirm|verify|challenge)[^\s"<>]*', body)
if links:
M.logout()
return {"link": links[0]}
M.logout()
except Exception as e:
log(f"imap poll: {e}")
time.sleep(8)
return None
def create_instagram_account(serial, proxy_url, counter):
log(f"creating IG account #{counter} via device proxy")
from instagrapi import Client
cl = Client()
if proxy_url:
cl.set_proxy(proxy_url)
email = f"igbot{counter}+{GMAIL}"
username = f"fleet.jones{counter}"
status = "created"
try:
cl.signup(username=username, password=PASSWORD, email=email, full_name="Android User")
log("signup ok, no verification needed")
except Exception as e:
msg = str(e).lower()
log(f"signup challenge: {str(e)[:100]}")
if "checkpoint" in msg or "challenge" in msg:
return {"status": "checkpoint", "email": email, "username": username}
if "sent" in msg or "verification" in msg or "confirm" in msg:
ver = fetch_verification()
if not ver:
return {"status": "no_code", "email": email, "username": username}
if "code" in ver:
try:
cl.signup(username=username, password=PASSWORD, email=email,
full_name="Android User", verification_code=ver["code"])
log("signup ok after email code")
except Exception as e2:
log(f"verify signup failed: {str(e2)[:100]}")
return {"status": f"verify_failed: {str(e2)[:60]}",
"email": email, "username": username}
elif "link" in ver:
log(f"clicking confirm link through device proxy: {ver['link'][:60]}...")
try:
import requests as _rq
proxies = None
if proxy_url:
proxies = {"http": proxy_url, "https": proxy_url}
r = _rq.get(ver["link"], proxies=proxies, timeout=20,
headers={"User-Agent": "Mozilla/5.0 (Linux; Android 9) AppleWebKit/537.36"})
log(f"link click: HTTP {r.status_code}")
time.sleep(5)
try:
cl.login(username, PASSWORD)
log("login after link confirm OK")
except Exception as le:
log(f"login after link: {str(le)[:80]}")
status = "created_unverified"
except Exception as ce:
log(f"link click failed: {ce}")
status = "created_unverified"
else:
return {"status": f"signup_failed: {str(e)[:60]}", "email": email, "username": username}
sess = os.path.join(SESSIONS_DIR, f"{username}.json")
cl.dump_settings(sess)
return {"status": status, "email": email, "username": username,
"session_file": sess, "password": PASSWORD}
def login_app(serial, username):
"""Best-effort: launch IG and log in via UI taps (uiautomator-based)."""
log("launching IG app for login")
adb_shell(serial, "am start -n com.instagram.android/.activity.MainTabActivity", 15)
time.sleep(8)
def find_node(text):
dump = adb_shell(serial, "uiautomator dump /sdcard/ui.xml && cat /sdcard/ui.xml", 20)
m = re.search(r'text="[^"]*' + re.escape(text) + r'[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', dump)
if m:
x = (int(m.group(1)) + int(m.group(3))) // 2
y = (int(m.group(2)) + int(m.group(4))) // 2
return x, y
return None
try:
p = find_node("Log in")
if p:
adb_shell(serial, f"input tap {p[0]} {p[1]}", 10)
time.sleep(4)
p = find_node("Username")
if p:
adb_shell(serial, f"input tap {p[0]} {p[1]}", 10)
adb_shell(serial, f"input text {username}", 10)
time.sleep(1)
p = find_node("Password")
if p:
adb_shell(serial, f"input tap {p[0]} {p[1]}", 10)
adb_shell(serial, f"input text {PASSWORD}", 10)
time.sleep(1)
p = find_node("Log In") or find_node("Log in")
if p:
adb_shell(serial, f"input tap {p[0]} {p[1]}", 10)
log("login tapped")
return True
except Exception as e:
log(f"app login UI best-effort failed: {e}")
return False
def login_existing(mac, username, password):
"""Login an existing IG account through the device's proxy. Session saved."""
dev = get_device(mac)
if not dev or not dev["ip"]:
return
con = dbcon()
pr = con.execute("SELECT * FROM proxies WHERE assigned_mac=?", (mac,)).fetchone()
con.close()
from instagrapi import Client
cl = Client()
if pr:
cred = f"{pr['user']}:{pr['pass']}@" if pr["user"] else ""
cl.set_proxy(f"socks5://{cred}{pr['host']}:{pr['port']}")
try:
cl.login(username, password)
sess = os.path.join(SESSIONS_DIR, f"{username}.json")
cl.dump_settings(sess)
con = dbcon()
con.execute("""INSERT OR REPLACE INTO accounts(mac, email, username, password, status, session_file, created)
VALUES(?,?,?,?,?,?,?)""",
(mac, None, username, password, "logged_in", sess, time.time()))
con.commit()
con.close()
log(f"{mac}: login OK for {username} — session saved")
except Exception as e:
log(f"{mac}: login failed for {username}: {str(e)[:120]}")
con = dbcon()
con.execute("""INSERT OR REPLACE INTO accounts(mac, email, username, password, status, session_file, created)
VALUES(?,?,?,?,?,?,?)""",
(mac, None, username, password, f"login_failed: {str(e)[:60]}", None, time.time()))
con.commit()
con.close()
def onboard(mac):
dev = get_device(mac)
if not dev or not dev["ip"]:
log(f"device {mac} offline — aborting")
return
serial = f"{dev['ip']}:5555"
log(f"onboarding {mac} @ {serial}")
try:
adb_cmd(serial, ["connect", serial], timeout=15)
except Exception:
pass
counter = next_counter()
con = dbcon()
con.execute("INSERT OR REPLACE INTO accounts(mac, status, created) VALUES(?,?,?)",
(mac, "onboarding", time.time()))
con.commit()
con.close()
setup_basics(serial)
try:
adb_cmd(serial, ["root"], timeout=15)
time.sleep(2)
adb_cmd(serial, ["connect", serial], timeout=15)
except Exception:
pass
apk_state = install_instagram(serial)
# proxy for this device
con = dbcon()
pr = con.execute("SELECT * FROM proxies WHERE assigned_mac=?", (mac,)).fetchone()
con.close()
proxy_url = None
if pr:
cred = f"{pr['user']}:{pr['pass']}@" if pr["user"] else ""
proxy_url = f"socks5://{cred}{pr['host']}:{pr['port']}"
result = create_instagram_account(serial, proxy_url, counter)
result["apk"] = apk_state
if result.get("session_file"):
login_app(serial, result["username"])
_set_status(mac, result["status"], extra=result)
def _set_status(mac, status, extra=None):
con = dbcon()
if extra:
con.execute("""UPDATE accounts SET status=?, email=?, username=?, password=?,
session_file=?, created=? WHERE mac=?""",
(status, extra.get("email"), extra.get("username"),
extra.get("password", PASSWORD), extra.get("session_file"),
time.time(), mac))
else:
con.execute("UPDATE accounts SET status=? WHERE mac=?", (status, mac))
con.commit()
con.close()
log(f"account status: {status}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: onboard.py <mac>")
sys.exit(1)
onboard(sys.argv[1])

215
proxy_harvest.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
ProxyFly integration: harvest free proxies -> live-test -> cleanliness-grade
(hosting/proxy flags + Spamhaus DNSBL) -> feed the fleet proxy bank.
Every machine gets a verified egress IP, 100% backend, zero-touch.
"""
import json
import os
import socket
import sqlite3
import threading
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
DB_PATH = "/opt/android-fleet/fleet.db"
SOURCES = [
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.json",
]
MAX_FETCH = 200 # candidates per cycle
MAX_TEST = 120 # live-test at most this many per cycle
MAX_POOL = 40 # cap the proxies table (perf)
TEST_TIMEOUT = 7
HARVEST_INTERVAL = 1500 # 25 min
def log(msg):
print(f"[harvest] {msg}", flush=True)
def dbcon():
con = sqlite3.connect(DB_PATH, timeout=30)
con.row_factory = sqlite3.Row
return con
def fetch_candidates():
cands = []
for src in SOURCES:
try:
with urllib.request.urlopen(src, timeout=45) as r:
data = json.loads(r.read())
for p in data:
proto = p.get("protocol", "")
if proto not in ("socks5", "https", "http", "socks4"):
continue
if p.get("anonymity") == "transparent":
continue # leaks real IP — useless for the fleet
cands.append({
"host": p["ip"], "port": p["port"], "proto": proto,
"country": (p.get("geolocation") or {}).get("country", "?"),
})
except Exception as e:
log(f"source error {src}: {e}")
# dedupe by host:port
seen = set()
out = []
for c in cands:
k = f"{c['host']}:{c['port']}"
if k not in seen:
seen.add(k)
out.append(c)
return out[:MAX_FETCH]
def test_proxy(c):
"""Returns (c, egress_ip, latency_ms) or (c, None, None)."""
proto_map = {"socks5": "socks5h://", "https": "https://", "http": "http://",
"socks4": "socks4://"}
url = proto_map[c["proto"]] + f"{c['host']}:{c['port']}"
import urllib.request as ur
opener = ur.build_opener(ur.ProxyHandler({"http": url, "https": url}))
t0 = time.time()
try:
with opener.open("https://api.ipify.org", timeout=TEST_TIMEOUT) as r:
ip = r.read().decode().strip()
lat = int((time.time() - t0) * 1000)
return (c, ip, lat)
except Exception:
return (c, None, None)
def check_clean(ips):
"""ip-api batch: flags for each ip. Returns {ip: dict}."""
out = {}
ips = [i for i in ips if i]
for i in range(0, len(ips), 15):
batch = ips[i:i + 15]
try:
req = urllib.request.Request(
"http://ip-api.com/batch?fields=status,country,isp,as,hosting,proxy,mobile,query",
data=json.dumps(batch).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=20) as r:
res = json.loads(r.read())
for e in res:
if e.get("status") == "success":
out[e["query"]] = e
except Exception as e:
log(f"ip-api batch error: {e}")
time.sleep(4) # 45 req/min limit — throttle hard
return out
def dnsbl_listed(ip):
"""Spamhaus ZEN: True if listed."""
try:
rev = ".".join(reversed(ip.split("."))) + ".zen.spamhaus.org"
socket.gethostbyname(rev)
return True
except socket.gaierror:
return False
except Exception:
return False
def prune_pool():
con = dbcon()
rows = con.execute("SELECT id, egress_ip, source FROM proxies ORDER BY id").fetchall()
# remove duplicates by egress ip (keep lowest id)
seen = set()
for r in rows:
if r["egress_ip"] and r["egress_ip"] in seen:
con.execute("DELETE FROM proxies WHERE id=?", (r["id"],))
elif r["egress_ip"]:
seen.add(r["egress_ip"])
# cap pool size (drop oldest proxifly entries beyond cap)
over = [r for r in rows if r["source"] == "proxifly"]
if len(over) > MAX_POOL:
for r in over[:-MAX_POOL]:
con.execute("DELETE FROM proxies WHERE id=?", (r["id"],))
con.commit()
con.close()
def harvest():
log(f"cycle start — fetching {len(SOURCES)} source(s)")
cands = fetch_candidates()
log(f"{len(cands)} candidates, testing up to {MAX_TEST}")
tested = []
with ThreadPoolExecutor(max_workers=20) as ex:
for c, ip, lat in ex.map(test_proxy, cands[:MAX_TEST]):
if ip:
c["egress_ip"] = ip
c["latency_ms"] = lat
tested.append(c)
log(f"{len(tested)} proxies alive; grading cleanliness")
flags = check_clean([c["egress_ip"] for c in tested])
dns_flags = {}
with ThreadPoolExecutor(max_workers=20) as ex:
for c in tested:
dns_flags[c["egress_ip"]] = ex.submit(dnsbl_listed, c["egress_ip"])
for k, f in dns_flags.items():
dns_flags[k] = f.result()
con = dbcon()
added = 0
for c in tested:
f = flags.get(c["egress_ip"], {})
dns = dns_flags.get(c["egress_ip"], False)
clean = 1 if (not f.get("hosting") and not f.get("proxy") and not dns) else 0
mobile = 1 if f.get("mobile") else 0
# keep only clean ones + a few fallbacks, skip dirty datacenter spam
if clean == 0:
why = "hosting" if f.get("hosting") else ("proxy" if f.get("proxy") else ("dnsbl" if dns else "unknown"))
log(f"rejected {c['egress_ip']} ({why})")
continue
name = f"proxifly-{c['country']}-{c['egress_ip'].split('.')[-2]}"
con.execute("""INSERT INTO proxies(name, host, port, proto, health, latency_ms,
assigned_mac, last_check, clean, country, egress_ip, source)
VALUES(?,?,?,?,'healthy',?,NULL,?,?,?,?,?)
ON CONFLICT(egress_ip) DO UPDATE SET health='healthy', latency_ms=?,
last_check=?, clean=?""",
(name, c["host"], c["port"], c["proto"], c["latency_ms"], time.time(),
clean, c["country"], c["egress_ip"], "proxifly",
c["latency_ms"], time.time(), clean))
added += 1
con.commit()
con.close()
prune_pool()
log(f"cycle done: +{added} clean proxies into the bank")
assign_best()
log("auto-assignment sweep complete")
def assign_best():
"""Give every online device without a proxy the best clean one available."""
con = dbcon()
con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_prox_egress ON proxies(egress_ip)")
devs = con.execute("SELECT * FROM devices WHERE adb_state='online' AND ip IS NOT NULL").fetchall()
for d in devs:
have = con.execute("SELECT * FROM proxies WHERE assigned_mac=?", (d["mac"],)).fetchone()
if have:
continue # already assigned — keep stable
best = con.execute("""SELECT * FROM proxies
WHERE clean=1 AND health='healthy' AND assigned_mac IS NULL
ORDER BY latency_ms ASC LIMIT 1""").fetchone()
if not best:
best = con.execute("""SELECT * FROM proxies
WHERE health='healthy' AND assigned_mac IS NULL
ORDER BY latency_ms ASC LIMIT 1""").fetchone()
if not best:
continue
con.execute("UPDATE proxies SET assigned_mac=? WHERE id=?", (d["mac"], best["id"]))
con.execute("UPDATE devices SET proxy_name=?, public_ip=? WHERE mac=?",
(best["name"], best["egress_ip"], d["mac"]))
log(f"assigned {best['name']} (egress {best['egress_ip']}, clean={best['clean']}) -> {d['mac'][:14]}")
con.commit()
con.close()
def loop():
while True:
time.sleep(HARVEST_INTERVAL)
try:
harvest()
except Exception as e:
log(f"cycle error: {e}")
if __name__ == "__main__":
log("proxyfly harvest engine starting")
try:
harvest()
except Exception as e:
log(f"startup harvest error: {e}")
loop()

457
static/index.html Normal file
View File

@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ANDROID FLEET — GODHEAD</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.7.0/lib/xterm-addon-fit.min.js"></script>
<style>
body { background:#0a0a0f; color:#e4e4e7; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
background-image: radial-gradient(circle, #17171f 1px, transparent 1px); background-size: 26px 26px; }
.panel { background:#12121a; border:1px solid #23232e; border-radius: 0.75rem; }
.card:hover { box-shadow: 0 0 24px rgba(99,102,241,.18); border-color:#3b3b52; }
.grad { background: linear-gradient(90deg,#6366f1,#d946ef); -webkit-background-clip:text; background-clip:text; color:transparent; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pulse { animation: pulse 2s cubic-bezier(.4,0,.6,1) infinite; }
@keyframes pulse { 50% { opacity:.35; } }
.bar { height:6px; border-radius:99px; background:#23232e; overflow:hidden; }
.bar > div { height:100%; border-radius:99px; transition: width .8s; }
::-webkit-scrollbar { width:8px; height:8px; } ::-webkit-scrollbar-thumb { background:#23232e; border-radius:8px; }
.tile-num { font-size: 1.6rem; font-weight: 700; font-family: ui-monospace, Menlo, monospace; }
button { transition: all .15s; } button:active { transform: scale(.96); }
</style>
</head>
<body class="min-h-screen p-4 md:p-6">
<div class="max-w-[1600px] mx-auto space-y-4">
<!-- header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-black tracking-widest grad">ANDROID FLEET — GODHEAD</h1>
<p class="text-[11px] text-zinc-500 mono">autonomous android spawner · proxy switchboard · live telemetry</p>
</div>
<div class="flex items-center gap-3">
<span class="mono text-zinc-400 text-sm" id="clock">--:--:--</span>
<span id="wsdot" class="flex items-center gap-1.5 text-xs mono text-zinc-500">
<span id="wsdot-led" class="w-2.5 h-2.5 rounded-full bg-zinc-600 inline-block"></span>WS</span>
</div>
</div>
<!-- stats strip -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-3" id="stats"></div>
<!-- controls -->
<div class="panel p-3 flex flex-wrap items-center gap-3">
<span class="text-xs font-bold tracking-wider text-zinc-400">SPAWN</span>
<input id="spawnCount" type="number" min="1" max="10" value="1"
class="w-16 bg-black/40 border border-zinc-700 rounded px-2 py-1 text-sm mono">
<input id="spawnPrefix" placeholder="name prefix (android)" value="android"
class="w-40 bg-black/40 border border-zinc-700 rounded px-2 py-1 text-sm mono">
<button onclick="spawn()" class="px-4 py-1.5 rounded bg-gradient-to-r from-indigo-500 to-fuchsia-500 text-white text-sm font-bold">
⚡ SPAWN
</button>
<div id="spawnStatus" class="text-xs mono text-zinc-400"></div>
<div class="ml-auto text-[11px] mono text-zinc-600">discovery: LAN :5555 scan + ARP · every 10s</div>
</div>
<!-- charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">FLEET BANDWIDTH (KB/s)</div><div class="relative h-44"><canvas id="chBw"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">CPU % / MEM % (fleet avg)</div><div class="relative h-44"><canvas id="chCpu"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">ONLINE DEVICES</div><div class="relative h-44"><canvas id="chOn"></canvas></div></div>
<div class="panel p-3"><div class="text-xs font-bold text-zinc-400 mb-1">PROXY LATENCY (ms)</div><div class="relative h-44"><canvas id="chPx"></canvas></div></div>
</div>
<!-- fleet grid -->
<div id="fleet" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"></div>
<div id="empty" class="panel p-10 text-center">
<div class="text-5xl mb-3 pulse">📱</div>
<div class="text-lg font-bold text-zinc-400">NO DEVICES — awaiting spawn</div>
<div class="text-xs mono text-zinc-600 mt-1">spawn a fleet member and it will auto-register here within seconds</div>
</div>
<!-- proxies + log -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="panel p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-bold tracking-wider text-zinc-400">PROXY BANK</span>
<button onclick="showProxyAdd()" class="text-xs px-2 py-1 rounded bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/40">+ ADD</button>
</div>
<div id="proxies" class="space-y-1.5 text-xs"></div>
</div>
<div class="panel p-3 text-xs">
<span class="font-bold tracking-wider text-zinc-400">PROXYFLY ENGINE</span>
<div class="text-zinc-500 mt-1">auto-harvest · test · clean-check (ip-api + Spamhaus) · assign — every 45 min, zero touch</div>
</div>
<div class="panel p-3">
<span class="text-xs font-bold tracking-wider text-zinc-400 mb-2 inline-block">IG ACCOUNTS</span>
<div id="accts" class="space-y-1.5 text-xs"></div>
</div>
<div class="panel p-3 flex flex-col">
<span class="text-xs font-bold tracking-wider text-zinc-400 mb-2">EVENT LOG</span>
<div id="log" class="mono text-[11px] space-y-1 max-h-64 overflow-y-auto"></div>
</div>
</div>
</div>
<!-- modals -->
<div id="modal" class="hidden fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div class="panel p-4 w-full max-w-2xl max-h-[85vh] overflow-y-auto">
<div class="flex justify-between items-center mb-2">
<span id="modalTitle" class="font-bold text-sm"></span>
<button onclick="closeModal()" class="text-zinc-400 hover:text-white text-lg">✕</button>
</div>
<div id="modalBody"></div>
</div>
</div>
<!-- live screen hover popup -->
<div id="hoverShot" class="hidden fixed z-50 pointer-events-none panel p-1" style="width:264px">
<img id="hoverImg" class="w-full rounded" src="" alt="live">
<div class="text-[9px] mono text-zinc-400 text-center mt-1">◉ live screen</div>
</div>
<script>
const WS_URL = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
let ws, devices = [], metrics = {}, proxies = [];
let bwHist = [], cpuHist = [], memHist = [], onHist = [];
const MAXPTS = 60;
let charts = {};
function mkChart(id, labels, datasets) {
return new Chart(document.getElementById(id), {
type: "line",
data: { labels, datasets },
options: {
animation: false, responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: "#a1a1aa", boxWidth: 10, font: { size: 10 } } } },
scales: {
x: { ticks: { color: "#52525b", maxTicksLimit: 8 }, grid: { color: "#1c1c26" } },
y: { beginAtZero: true, ticks: { color: "#52525b" }, grid: { color: "#1c1c26" } }
}
}
});
}
function initCharts() {
// NOTE: every chart canvas MUST sit in a fixed-height wrapper (h-44) —
// maintainAspectRatio:false without one makes Chart.js grow the page forever.
const c = (id, lab, col, fill) => mkChart(id, [], [{ label: lab, data: [], borderColor: col, backgroundColor: fill, borderWidth: 1.5, pointRadius: 0, tension: .3 }]);
charts.bw = mkChart("chBw", [], [
{ label: "rx", data: [], borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true },
{ label: "tx", data: [], borderColor: "#d946ef", backgroundColor: "rgba(217,70,239,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.cpu = mkChart("chCpu", [], [
{ label: "cpu%", data: [], borderColor: "#22c55e", backgroundColor: "rgba(34,197,94,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true },
{ label: "mem%", data: [], borderColor: "#f59e0b", backgroundColor: "rgba(245,158,11,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.on = mkChart("chOn", [], [{ label: "online", data: [], borderColor: "#22c55e", backgroundColor: "rgba(34,197,94,.15)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
charts.px = mkChart("chPx", [], [{ label: "latency ms", data: [], borderColor: "#f59e0b", backgroundColor: "rgba(245,158,11,.2)", borderWidth: 1.5, pointRadius: 0, tension: .3, fill: true }]);
}
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => { document.getElementById("wsdot-led").className = "w-2.5 h-2.5 rounded-full bg-green-500 inline-block pulse"; };
ws.onclose = () => { document.getElementById("wsdot-led").className = "w-2.5 h-2.5 rounded-full bg-red-500 inline-block"; setTimeout(connect, 3000); };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === "devices") { devices = m.data; renderFleet(); renderStats(); }
if (m.type === "metrics") { metrics = m.data; renderMetrics(); tickCharts(); }
if (m.type === "log") addLog(m.data);
};
}
function tickCharts() {
const t = new Date().toLocaleTimeString("en-US", { hour12: false }).slice(0, 8);
let rx = 0, tx = 0, cpu = 0, mem = 0, n = 0;
for (const k in metrics) {
const d = metrics[k];
rx += d.rx_kb || 0; tx += d.tx_kb || 0;
if (d.cpu_pct != null) { cpu += d.cpu_pct; n++; }
if (d.mem_total_mb) mem += (d.mem_used_mb / d.mem_total_mb) * 100;
}
if (n) cpu /= n;
push(charts.bw, t, [rx, tx]);
push(charts.cpu, t, [cpu, n ? mem / n : 0]);
push(charts.on, t, [devices.filter(d => d.adb_state === "online").length]);
const lat = proxies.map(p => p.latency_ms || 0);
if (lat.length) charts.px.data.labels.push(t), charts.px.data.datasets[0].data.push(lat.reduce((a, b) => a + b, 0) / lat.length);
for (const k in charts) {
while (charts[k].data.labels.length > MAXPTS) { charts[k].data.labels.shift(); charts[k].data.datasets.forEach(ds => ds.data.shift()); }
charts[k].update("none");
}
}
function push(ch, label, vals) {
ch.data.labels.push(label);
ch.data.datasets.forEach((ds, i) => ds.data.push(vals[i]));
}
function renderStats() {
const online = devices.filter(d => d.adb_state === "online");
const ph = proxies.filter(p => p.health === "healthy");
const cpu = online.length ? (online.reduce((a, d) => a + (d.cpu_pct || 0), 0) / online.length) : 0;
const mem = online.length ? (online.reduce((a, d) => a + ((d.mem_total_mb ? d.mem_used_mb / d.mem_total_mb : 0)), 0) / online.length * 100) : 0;
let bw = 0; for (const k in metrics) bw += (metrics[k].rx_kb || 0) + (metrics[k].tx_kb || 0);
const tiles = [
["DEVICES ONLINE", `${online.length}/${devices.length}`, "text-green-400"],
["KNOWN DEVICES", devices.length, "text-indigo-300"],
["PROXIES HEALTHY", `${ph.length}/${proxies.length}`, "text-amber-300"],
["AVG CPU", cpu.toFixed(0) + "%", "text-fuchsia-300"],
["AVG MEM", mem.toFixed(0) + "%", "text-orange-300"],
["NET THROUGHPUT", (bw / 1024).toFixed(2) + " MB/s", "text-cyan-300"],
];
document.getElementById("stats").innerHTML = tiles.map(([l, v, c]) =>
`<div class="panel p-3"><div class="text-[10px] font-bold tracking-widest text-zinc-500">${l}</div>
<div class="tile-num ${c}">${v}</div></div>`).join("");
}
function renderFleet() {
const el = document.getElementById("fleet");
document.getElementById("empty").style.display = devices.length ? "none" : "";
el.innerHTML = devices.map(d => {
const online = d.adb_state === "online";
const badge = online ? '<span class="px-2 py-0.5 rounded bg-green-500/20 text-green-400 text-[10px] font-bold">ONLINE</span>'
: d.adb_state === "unknown" ? '<span class="px-2 py-0.5 rounded bg-zinc-500/20 text-zinc-400 text-[10px] font-bold">UNKNOWN</span>'
: '<span class="px-2 py-0.5 rounded bg-red-500/20 text-red-400 text-[10px] font-bold">OFFLINE</span>';
const mt = d.mem_total_mb ? Math.round(d.mem_used_mb / d.mem_total_mb * 100) : 0;
const m = metrics[d.mac] || {};
const flag = d.public_ip && d.proxy_name ? "🌍" : "";
const up = d.first_seen ? humanize(Date.now() / 1000 - d.first_seen) : "—";
return `<div class="panel card p-3 space-y-2" data-mac="${d.mac}" onmouseenter="hoverOn('${d.mac}',event)" onmouseleave="hoverOff()">
<div class="flex items-center justify-between">
<div class="font-bold text-sm">${esc(d.label || d.model || "android")}</div>${badge}
</div>
<div class="mono text-[10px] text-zinc-500 grid grid-cols-2 gap-x-2 gap-y-0.5">
<span>IP <span class="text-zinc-300">${d.ip || "—"}</span></span>
<span>VMID <span class="text-zinc-300">${d.vmid || "?"}</span></span>
<span>MAC <span class="text-zinc-300">${(d.mac || "").slice(0, 14)}</span></span>
<span>AND <span class="text-zinc-300">${d.android || "?"}</span></span>
<span>EGRESS <span class="text-zinc-300">${d.public_ip || "none"}</span> ${flag}</span>
<span>PROXY <span class="text-amber-300">${d.proxy_name || "—"}</span></span>
<span>UP <span class="text-zinc-300">${up}</span></span>
<span>BAT <span class="text-zinc-300">${d.battery != null ? d.battery + "%" : "—"}</span></span>
</div>
<div class="space-y-1">
<div class="flex justify-between text-[10px] mono text-zinc-500"><span>CPU</span><span>${Math.round(m.cpu_pct || d.cpu_pct || 0)}%</span></div>
<div class="bar"><div style="width:${Math.min(100, m.cpu_pct || d.cpu_pct || 0)}%; background:linear-gradient(90deg,#6366f1,#d946ef)"></div></div>
<div class="flex justify-between text-[10px] mono text-zinc-500"><span>MEM</span><span>${mt}%</span></div>
<div class="bar"><div style="width:${mt}%; background:linear-gradient(90deg,#22c55e,#84cc16)"></div></div>
</div>
<div class="flex gap-1.5 flex-wrap">
<button onclick="doTerm('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/40">TERMINAL</button>
<button onclick="doOnboard('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-fuchsia-500/20 text-fuchsia-300 hover:bg-fuchsia-500/40">ONBOARD</button>
<button onclick="doShell('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SHELL</button>
<button onclick="doShot('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">SCREENSHOT</button>
<button onclick="doReboot('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">REBOOT</button>
<button onclick="doLabel('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-zinc-700/50 hover:bg-zinc-600">LABEL</button>
<button onclick="doKill('${d.mac}')" class="text-[10px] px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/40">KILL</button>
</div>
</div>`;
}).join("");
}
function renderMetrics() {}
function renderProxies() {
document.getElementById("proxies").innerHTML = proxies.map(p => {
const h = p.health === "healthy" ? "text-green-400" : p.health === "dead" ? "text-red-400" : "text-zinc-400";
return `<div class="flex items-center justify-between bg-black/30 rounded px-2 py-1.5">
<div><span class="font-bold">${esc(p.name)}</span> <span class="mono text-zinc-500">${esc(p.country || "")} · ${p.host}:${p.port}</span>
${p.clean ? '<span class="text-[10px] text-green-400 font-bold"> ✔ clean</span>' : '<span class="text-[10px] text-zinc-600"> · unchecked</span>'}
${p.assigned_mac ? `<span class="text-[10px] text-indigo-300 mono">→ ${p.assigned_mac.slice(0, 14)}</span>` : ""}</div>
<div class="flex items-center gap-2">
<span class="${h}">● ${p.health} ${p.latency_ms ? p.latency_ms + "ms" : ""}</span>
<button onclick="doDelProxy(${p.id})" class="text-zinc-600 hover:text-red-400">✕</button>
</div></div>`;
}).join("");
}
function addLog(e) {
const el = document.getElementById("log");
const colors = { DEVICE_JOINED: "text-green-400", SPAWN: "text-indigo-300", SPAWN_DONE: "text-green-400", SPAWN_ERROR: "text-red-400", ERROR: "text-red-400", DEVICE_OFFLINE: "text-orange-300", DEVICE_DISCONNECTED: "text-orange-300", PROXY_ASSIGNED: "text-amber-300", KILL: "text-red-400", STARTUP: "text-cyan-300", WARN: "text-amber-300" };
const c = colors[e.type] || "text-zinc-400";
el.insertAdjacentHTML("afterbegin", `<div class="${c}">${new Date(e.ts * 1000).toLocaleTimeString("en-US", { hour12: false })} [${e.type}] ${esc(e.msg)}</div>`);
while (el.children.length > 40) el.removeChild(el.lastChild);
}
async function api(path, opts = {}) {
const r = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts });
return r.json();
}
async function spawn() {
const count = parseInt(document.getElementById("spawnCount").value) || 1;
const prefix = document.getElementById("spawnPrefix").value || "android";
const s = document.getElementById("spawnStatus");
s.textContent = "spawning…";
const r = await api("/api/spawn", { method: "POST", body: JSON.stringify({ count, name_prefix: prefix }) });
s.textContent = r.error || `job #${r.job_id} queued — watching…`;
const poll = setInterval(async () => {
const jobs = await api("/api/spawn/jobs");
const j = jobs.find(x => x.id === r.job_id);
if (!j || ["done", "error"].includes(j.status)) {
clearInterval(poll);
s.textContent = j && j.status === "done" ? `✅ ${j.done} devices spawned: ${JSON.parse(j.vmid_list || "[]").join(", ")}`
: `❌ ${(j && j.error) || "failed"}`;
} else {
s.textContent = `job #${r.job_id}: ${j.done}/${j.count} spawned…`;
}
}, 3000);
}
async function doShell(mac) {
const cmd = prompt("whitelisted cmd — getprop | dumpsys battery | cat /proc/meminfo | pm list packages | settings list | ip addr | wm size | logcat -d | ls | df", "getprop ro.build.fingerprint");
if (!cmd) return;
openModal("SHELL — " + mac.slice(0, 14), "<pre class='mono text-xs text-zinc-300 whitespace-pre-wrap'>running…</pre>");
try {
const r = await api(`/api/device/${mac}/shell`, { method: "POST", body: JSON.stringify({ cmd }) });
document.getElementById("modalBody").innerHTML = `<pre class='mono text-xs text-zinc-300 whitespace-pre-wrap'>${esc(r.output || r.error)}</pre>`;
} catch (e) { document.getElementById("modalBody").innerHTML = "error"; }
}
async function doShot(mac) {
openModal("SCREENSHOT — " + mac.slice(0, 14), "<div class='text-xs text-zinc-400'>capturing…</div>");
await api(`/api/device/${mac}/screenshot`, { method: "POST" }).catch(() => {});
document.getElementById("modalBody").innerHTML =
`<img src="/api/device/${mac}/screenshot/latest?t=${Date.now()}" class="w-full rounded border border-zinc-700">
<button onclick="doShot('${mac}')" class="mt-2 text-xs px-3 py-1 rounded bg-indigo-500/30 text-indigo-300">↻ refresh</button>`;
}
async function doReboot(mac) { await api(`/api/device/${mac}/reboot`, { method: "POST" }); addLog({ ts: Date.now() / 1000, type: "WARN", msg: `reboot sent to ${mac.slice(0, 14)}` }); }
async function doLabel(mac) {
const label = prompt("device label", "");
if (!label) return;
await api("/api/devices", { method: "PATCH", body: JSON.stringify({ mac, label }) });
const r = await api("/api/devices"); devices = r; renderFleet();
}
async function doKill(mac) {
if (!confirm("DESTROY this VM? This deletes the Proxmox VM permanently.")) return;
await api(`/api/device/${mac}/kill`, { method: "POST" });
addLog({ ts: Date.now() / 1000, type: "KILL", msg: `kill sent to ${mac.slice(0, 14)}` });
}
function showProxyAdd() {
openModal("ADD PROXY", `
<div class="space-y-2 text-sm">
<input id="pxName" placeholder="name (nord-london)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxHost" placeholder="host (10.30.20.xx)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxPort" placeholder="port (1080)" type="number" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxUser" placeholder="user (optional)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<input id="pxPass" placeholder="pass (optional)" class="w-full bg-black/40 border border-zinc-700 rounded px-2 py-1.5 mono">
<button onclick="addProxy()" class="w-full px-3 py-1.5 rounded bg-gradient-to-r from-indigo-500 to-fuchsia-500 text-white text-sm font-bold">SAVE</button>
</div>`);
}
async function addProxy() {
const body = {
name: document.getElementById("pxName").value,
host: document.getElementById("pxHost").value,
port: parseInt(document.getElementById("pxPort").value),
user: document.getElementById("pxUser").value,
pass: document.getElementById("pxPass").value,
proto: "socks5",
};
await api("/api/proxies", { method: "POST", body: JSON.stringify(body) });
closeModal(); loadProxies();
}
async function doDelProxy(id) {
if (!confirm("delete proxy?")) return;
await api(`/api/proxies/${id}`, { method: "DELETE" }); loadProxies();
}
async function loadProxies() { proxies = await api("/api/proxies"); renderProxies(); }
function openModal(title, body) {
document.getElementById("modalTitle").textContent = title;
document.getElementById("modalBody").innerHTML = body;
document.getElementById("modal").classList.remove("hidden");
}
function closeModal() {
document.getElementById("modal").classList.add("hidden");
if (window.termWs) { try { window.termWs.close(); } catch (e) {} window.termWs = null; }
if (window.term) { try { window.term.dispose(); } catch (e) {} window.term = null; }
}
// ---------- live screen hover popup ----------
let hoverTimer = null;
function hoverOn(mac, e) {
const hs = document.getElementById("hoverShot");
hs.classList.remove("hidden");
const refresh = () => {
document.getElementById("hoverImg").src = `/api/device/${mac}/screenshot/latest?t=${Date.now()}`;
};
refresh();
hoverTimer = setInterval(refresh, 2000);
const w = 264, h = 320;
let x = e.clientX + 14, y = e.clientY + 14;
if (x + w > innerWidth) x = e.clientX - w - 14;
if (y + h > innerHeight) y = innerHeight - h - 10;
hs.style.left = x + "px";
hs.style.top = y + "px";
}
function hoverOff() {
document.getElementById("hoverShot").classList.add("hidden");
if (hoverTimer) { clearInterval(hoverTimer); hoverTimer = null; }
}
// ---------- interactive terminal ----------
function doTerm(mac) {
openModal("TERMINAL — " + mac.slice(0, 14),
`<div id="termHost" class="bg-black rounded border border-zinc-700 p-2" style="height:420px"></div>
<div class="text-[10px] mono text-zinc-500 mt-1">interactive root shell · click and type</div>`);
const term = new Terminal({ cursorBlink: true, fontSize: 13, theme: { background: "#000000" } });
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById("termHost"));
fit.fit();
const ws = new WebSocket(`${WS_URL.replace(/\/ws$/, "")}/ws/shell/${mac}`);
window.term = term; window.termWs = ws;
term.onData((d) => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "input", data: d })); });
ws.onmessage = (m) => {
const d = JSON.parse(m.data);
if (d.type === "out") term.write(d.data);
if (d.type === "error") term.write("\r\n\x1b[31m" + d.data + "\x1b[0m\r\n");
};
window.addEventListener("resize", () => { try { fit.fit(); } catch (e) {} });
}
// ---------- onboarding ----------
async function doOnboard(mac) {
addLog({ ts: Date.now() / 1000, type: "SPAWN", msg: `onboarding started for ${mac.slice(0, 14)}` });
await api(`/api/device/${mac}/onboard`, { method: "POST" });
if (!window._acctPoll) {
window._acctPoll = setInterval(loadAccounts, 5000);
}
}
async function loadAccounts() {
const accts = await api("/api/accounts");
const el = document.getElementById("accts");
if (!el) return;
el.innerHTML = accts.length ? accts.map(a =>
`<div class="flex justify-between bg-black/30 rounded px-2 py-1">
<span class="font-bold">${esc(a.username || "—")}</span>
<span class="mono text-zinc-500">${esc(a.email || "")}</span>
<span class="${a.status === "created" ? "text-green-400" : "text-amber-300"}">${esc(a.status)}</span>
</div>`).join("") : '<div class="text-zinc-600 text-center py-2">no accounts yet — hit ONBOARD on a device</div>';
}
function esc(s) { return String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
function humanize(sec) {
sec = Math.max(0, Math.floor(sec));
if (sec < 60) return sec + "s";
if (sec < 3600) return Math.floor(sec / 60) + "m";
if (sec < 86400) return Math.floor(sec / 3600) + "h";
return Math.floor(sec / 86400) + "d";
}
document.getElementById("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
setInterval(() => { document.getElementById("clock").textContent = new Date().toLocaleTimeString(); }, 1000);
setInterval(loadProxies, 15000);
initCharts();
connect();
loadProxies();
loadAccounts();
</script>
</body>
</html>