Files
android-fleet-godhead/app.py

887 lines
35 KiB
Python

#!/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"),
("fail_count", "INTEGER DEFAULT 0")]:
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")
# purge ghosts: offline devices whose VM no longer exists in Proxmox
for row in con.execute("SELECT * FROM devices WHERE adb_state='offline' AND vmid IS NOT NULL").fetchall():
r = pve("GET", f"/nodes/pve/qemu/{row['vmid']}/config") or {}
if "does not exist" in str(r) or "no such VM" in str(r):
con.execute("UPDATE proxies SET assigned_mac=NULL WHERE assigned_mac=?",
(row["mac"],))
con.execute("DELETE FROM devices WHERE mac=?", (row["mac"],))
log("GHOST_PURGED", f"device {row['mac'][:14]} (vmid {row['vmid']}) — VM gone, row removed")
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))
if health == "healthy":
con.execute("UPDATE proxies SET health='healthy', latency_ms=?, last_check=?, fail_count=0 WHERE id=?",
(lat, time.time(), p["id"]))
else:
# 2-strike rule: free proxies flake; only die after consecutive failures
fc = (p["fail_count"] or 0) + 1
con.execute("UPDATE proxies SET fail_count=?, last_check=? WHERE id=?", (fc, time.time(), p["id"]))
if fc >= 2:
con.execute("UPDATE proxies SET health='dead' WHERE id=?", (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 = []
errors = []
for _ in range(count):
vmid = None
try:
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')}")
time.sleep(2)
cfg = {"args": args, "memory": 3072, "cores": 2}
applied = False
for _attempt in range(3):
pve("PUT", f"/nodes/pve/qemu/{vmid}/config", cfg)
time.sleep(2)
got = pve("GET", f"/nodes/pve/qemu/{vmid}/config")
cur = (got.get("data") or {}).get("args", "")
if f"59{display:02d}" in cur:
applied = True
break
time.sleep(3)
if not applied:
raise Exception(f"args did not apply for vmid {vmid}")
pve("POST", f"/nodes/pve/qemu/{vmid}/status/start", {})
vmids.append(vmid)
log("SPAWN", f"spawned {name} (vmid {vmid}, display {display})")
except Exception as e:
errors.append(f"vmid {vmid}: {str(e)[:80]}")
log("SPAWN_ERROR", f"per-vm failure ({vmid}): {str(e)[:80]} — batch continues")
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=?, error=? WHERE id=?",
(json.dumps(vmids), "; ".join(errors) or None, job_id))
con.commit()
con.close()
log("SPAWN_DONE", f"job {job_id}: {len(vmids)}/{count} vms up"
+ (f" — errors: {'; '.join(errors)}" if errors else ""))
@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")
VNC_TOKEN_FILE = "/opt/android-fleet/vnc_tokens.json"
def regen_vnc_tokens():
"""Map each online device MAC -> its QEMU VNC port for websockify TokenFile."""
try:
con = db()
rows = con.execute("SELECT mac, vmid FROM devices WHERE vmid IS NOT NULL").fetchall()
con.close()
lines = []
for r in rows:
got = pve("GET", f"/nodes/pve/qemu/{r['vmid']}/config") or {}
cfg = (got.get("data") or {}) or {}
args = cfg.get("args", "") or ""
m = re.search(r"-vnc 0\.0\.0\.0:(\d{4})", args)
if m:
port = int(m.group(1)) + 5900 # QEMU: -vnc :N listens on N+5900
lines.append(f"{r['mac']}: 10.30.20.85:{port}")
with open(VNC_TOKEN_FILE, "w") as f:
f.write("\n".join(lines) + ("\n" if lines else ""))
except Exception as e:
log("WARN", f"vnc token regen: {e}")
def vnc_loop():
while True:
regen_vnc_tokens()
time.sleep(60)
@app.route("/api/device/<mac>/vnc")
def api_vnc(mac):
regen_vnc_tokens()
try:
tokens = open(VNC_TOKEN_FILE).read()
except FileNotFoundError:
tokens = ""
if mac not in tokens:
return jsonify({"error": "device has no VNC mapping (needs vmid + -vnc args)"}), 404
return jsonify({"url": (f"http://10.30.20.31:8081/vnc.html?autoconnect=true"
f"&reconnect=true&resize=scale&path=websockify?token={mac}")})
@app.route("/api/device/<mac>", methods=["DELETE"])
def api_delete_device(mac):
"""Remove a device row (and release its proxy)."""
con = db()
con.execute("UPDATE proxies SET assigned_mac=NULL WHERE assigned_mac=?", (mac,))
con.execute("DELETE FROM devices WHERE mac=?", (mac,))
con.commit()
con.close()
log("DEVICE_DELETED", f"device {mac} removed manually")
return jsonify({"ok": True})
@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, vnc_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)