#!/usr/bin/python3 """BroV network core — narrow root helper (LaunchDaemon local.maksar.brov.netctl). The core (mihomo) runs as root with its config, keys and API secret in /Library/Application Support/BroV/netcore (root, 0700). BroV never sees those: it talks to this helper over a Unix socket that only the installing user may open, and the helper allows exactly these operations: state groups, provider nodes with delays, TUN on/off select {group, name} ai-out / amnezia / saga-sw / planet9-sw / home-sw, name must be a member delay {group}|{proxy} speed test of ai-out / amnezia, or of the saga / planet9 / home tunnel tun {on} traffic capture on/off add_key {text} vpn:// Amnezia key: checked by gen.py, stored, provider reloaded remove_key {name} "AWG " connection One JSON object per line in, one per line out. Nothing here can rewrite the core config or point traffic elsewhere. """ import json import os import re import socket import struct import subprocess import sys import threading import urllib.error import urllib.parse import urllib.request ROOT = "/Library/Application Support/BroV" CORE = os.path.join(ROOT, "netcore") KEYS = os.path.join(CORE, "keys") SOCK = "/var/run/brov-netctl.sock" API = "http://127.0.0.1:9097" TEST_URL = "https://www.gstatic.com/generate_204" SELECT_GROUPS = {"ai-out", "amnezia", "saga-sw", "planet9-sw", "home-sw"} DELAY_GROUPS = {"ai-out", "amnezia"} CLIENT_TUNNELS = {"saga", "planet9", "home"} MAX_REQUEST = 64 * 1024 ALLOWED_UID = int(sys.argv[1]) if len(sys.argv) > 1 else -1 gen_lock = threading.Lock() def secret(): with open(os.path.join(CORE, "api.secret")) as f: return f.read().strip() def api(method, path, body=None, timeout=8): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(API + path, method=method, data=data, headers={ "Authorization": "Bearer " + secret(), "Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: raw = r.read() return json.loads(raw) if raw else {} def q(name): return urllib.parse.quote(name, safe="") def gen(*args): return subprocess.run(["/usr/bin/python3", os.path.join(CORE, "gen.py"), *args], cwd=CORE, capture_output=True, text=True, timeout=60).stdout # MARK: - Commands def cmd_state(_): proxies = api("GET", "/proxies").get("proxies", {}) keep = {} for name, p in proxies.items(): keep[name] = {"now": p.get("now"), "all": p.get("all"), "history": (p.get("history") or [])[-1:]} providers = {} for prov in ("vless-cluster", "amnezia-keys"): try: lst = api("GET", "/providers/proxies/" + prov).get("proxies", []) except Exception: lst = [] providers[prov] = [{"name": x.get("name"), "history": (x.get("history") or [])[-1:]} for x in lst] tun = api("GET", "/configs").get("tun", {}).get("enable", False) return {"ok": True, "proxies": keep, "providers": providers, "tun": tun} def cmd_select(r): group, name = r.get("group"), r.get("name") if group not in SELECT_GROUPS or not isinstance(name, str): return {"ok": False, "error": "группа не разрешена"} members = api("GET", "/proxies/" + q(group)).get("all", []) if name not in members: return {"ok": False, "error": "такого варианта нет в группе"} api("PUT", "/proxies/" + q(group), {"name": name}) return {"ok": True} def cmd_delay(r): test = "url=" + q(TEST_URL) + "&timeout=5000" if r.get("group") in DELAY_GROUPS: return {"ok": True, "delays": api("GET", "/group/%s/delay?%s" % (q(r["group"]), test), timeout=10)} if r.get("proxy") in CLIENT_TUNNELS: try: d = api("GET", "/proxies/%s/delay?%s" % (q(r["proxy"]), test), timeout=10).get("delay", 0) except Exception: d = 0 return {"ok": True, "delays": {r["proxy"]: d}} return {"ok": False, "error": "замер не разрешён"} def cmd_tun(r): on = r.get("on") if not isinstance(on, bool): return {"ok": False, "error": "нужно on: true/false"} api("PATCH", "/configs", {"tun": {"enable": on}}) return {"ok": True} def provider_reload_and_test(proxy): api("PUT", "/providers/proxies/amnezia-keys") try: hc = api("GET", "/providers/proxies/amnezia-keys/%s/healthcheck?url=%s&timeout=6000" % (q(proxy), q(TEST_URL)), timeout=10) return hc.get("delay", 0) except Exception: return 0 def cmd_add_key(r): text = r.get("text") if not isinstance(text, str) or not text.strip().startswith("vpn://") or len(text) > 20000: return {"ok": False, "error": "ключ должен начинаться с vpn://"} with gen_lock: pending = os.path.join(KEYS, ".pending.vpnkey") fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, "w") as f: f.write(text.strip()) try: check = json.loads(gen("--check", pending).strip().splitlines()[-1]) except Exception: check = {"ok": False, "error": "не удалось разобрать ключ"} if not check.get("ok"): os.remove(pending) return {"ok": False, "error": check.get("error", "ключ не подходит")} slug = re.sub(r"[^a-z0-9]+", "-", str(check.get("name", "amnezia")).lower()).strip("-") or "amnezia" dest, n = os.path.join(KEYS, slug + ".vpnkey"), 2 while os.path.exists(dest): dest, n = os.path.join(KEYS, "%s-%d.vpnkey" % (slug, n)), n + 1 os.rename(pending, dest) gen() proxy = "AWG " + os.path.basename(dest)[:-len(".vpnkey")] return {"ok": True, "name": check.get("name"), "server": "%s:%s" % (check.get("server"), check.get("port")), "proxy": proxy, "delay": provider_reload_and_test(proxy)} def cmd_remove_key(r): name = r.get("name", "") m = re.fullmatch(r"AWG ([a-z0-9-]+)", name) if isinstance(name, str) else None if not m: return {"ok": False, "error": "неверное имя подключения"} path = os.path.join(KEYS, m.group(1) + ".vpnkey") if not os.path.isfile(path) or os.path.islink(path): return {"ok": False, "error": "такого подключения нет"} with gen_lock: if api("GET", "/proxies/amnezia").get("now") == name: api("PUT", "/proxies/amnezia", {"name": "amnezia-auto"}) os.remove(path) gen() api("PUT", "/providers/proxies/amnezia-keys") return {"ok": True} COMMANDS = {"state": cmd_state, "select": cmd_select, "delay": cmd_delay, "tun": cmd_tun, "add_key": cmd_add_key, "remove_key": cmd_remove_key} # MARK: - Socket server def peer_uid(conn): # LOCAL_PEERCRED (SOL_LOCAL=0, opt=1) → struct xucred { u_int cr_version; uid_t cr_uid; … } cred = conn.getsockopt(0, 1, 76) return struct.unpack_from("I", cred, 4)[0] def handle(conn): try: if peer_uid(conn) not in (0, ALLOWED_UID): return conn.settimeout(30) buf = b"" while b"\n" not in buf and len(buf) < MAX_REQUEST: chunk = conn.recv(8192) if not chunk: break buf += chunk req = json.loads(buf.split(b"\n", 1)[0] or b"{}") fn = COMMANDS.get(req.get("cmd")) if fn is None: resp = {"ok": False, "error": "неизвестная команда"} else: try: resp = fn(req) except urllib.error.URLError: resp = {"ok": False, "error": "ядро не отвечает"} except Exception as e: resp = {"ok": False, "error": str(e) or e.__class__.__name__} conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode()) except Exception: pass finally: conn.close() def main(): if ALLOWED_UID < 0: sys.exit("usage: netctl.py ") try: os.unlink(SOCK) except FileNotFoundError: pass srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) old = os.umask(0o177) srv.bind(SOCK) os.umask(old) os.chown(SOCK, ALLOWED_UID, -1) os.chmod(SOCK, 0o600) srv.listen(8) while True: conn, _ = srv.accept() threading.Thread(target=handle, args=(conn,), daemon=True).start() if __name__ == "__main__": main()