a7a0195d34
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
8.5 KiB
Python
236 lines
8.5 KiB
Python
#!/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 <slug>" 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 <uid allowed to connect>")
|
|
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()
|