327 lines
12 KiB
Python
327 lines
12 KiB
Python
#!/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 threading
|
|
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)
|
|
|
|
|
|
NORD_FALLBACK = "http://10.30.20.154:1081" # nord-tunnel HTTP CONNECT (instagrapi's socks client is broken)
|
|
COUNTER_FILE = "/opt/android-fleet/ig_counter"
|
|
COUNTER_LOCK = threading.Lock()
|
|
|
|
def next_counter():
|
|
"""Atomic account counter — never reuse an email alias."""
|
|
with COUNTER_LOCK:
|
|
n = 1
|
|
if os.path.exists(COUNTER_FILE):
|
|
try:
|
|
n = int(open(COUNTER_FILE).read().strip() or 0) + 1
|
|
except ValueError:
|
|
n = 1
|
|
open(COUNTER_FILE, "w").write(str(n))
|
|
return n
|
|
|
|
|
|
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 "429" in str(e) or "rate" in msg:
|
|
return {"status": "rate_limited_ip", "email": email, "username": username,
|
|
"session_file": None}
|
|
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)
|
|
|
|
# SIGNUP ALWAYS USES THE STABLE NORD TUNNEL — free-pool device proxies are
|
|
# too flaky for the account-creation handshake (dead SOCKS = instant fail).
|
|
result = None
|
|
try:
|
|
result = create_instagram_account(serial, NORD_FALLBACK, counter)
|
|
except Exception as e:
|
|
log(f"onboard crash: {str(e)[:100]}")
|
|
result = {"status": f"onboard_error: {str(e)[:60]}", "email": None,
|
|
"username": None, "session_file": None}
|
|
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])
|