Files

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()