android-fleet-godhead v1: fleet control node + ProxyFly engine + dashboard
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user