android-fleet-godhead v1: fleet control node + ProxyFly engine + dashboard
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
config.json
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
15
android-fleet.service
Normal file
15
android-fleet.service
Normal 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
15
android-harvest.service
Normal 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
807
app.py
Normal 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
10
config.json.example
Normal 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
92
docs/MASTER_PROMPT.md
Normal 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
73
docs/README.md
Normal 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
313
onboard.py
Normal 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
215
proxy_harvest.py
Normal 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
457
static/index.html
Normal 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|
||||||
Reference in New Issue
Block a user