Security + upstream fixes
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore) - Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days - Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BroV network core prototype: builds core/config.yaml for mihomo from src/*.
|
||||
|
||||
keys/*.vpnkey one vpn:// key per Amnezia connection (AmneziaWG 2/3); BroV adds them
|
||||
and rewrites keys/amnezia.yaml itself, gen.py does the same on a full build
|
||||
src/saga.conf WireGuard client config -> only 192.168.8.0/24
|
||||
src/planet9.conf WireGuard client config -> only 192.168.68.0/24
|
||||
src/vless.sub 3x-ui subscription URL (all VLESS nodes)
|
||||
|
||||
Secrets stay in this private folder; nothing here goes to git.
|
||||
"""
|
||||
import base64, json, os, re, secrets, zlib, configparser
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(HERE, "src")
|
||||
KEYS = os.path.join(HERE, "keys")
|
||||
# Installed next to its inputs in /Library/Application Support/BroV/netcore (root, 0700):
|
||||
# the root core reads its config from here, nothing user-writable is involved.
|
||||
CORE = HERE
|
||||
|
||||
HOME_NET = "192.168.10.0/24"
|
||||
CLIENTS = { # name: (conf file, routed subnets)
|
||||
"saga": ("saga.conf", ["192.168.8.0/24", "10.0.0.0/24"]),
|
||||
"planet9": ("planet9.conf", ["192.168.68.0/24", "172.3.3.0/24"]),
|
||||
}
|
||||
AI_DOMAINS = ["anthropic.com", "claude.ai", "claude.com", "openai.com", "chatgpt.com",
|
||||
"oaistatic.com", "oaiusercontent.com", "github.com", "githubusercontent.com"]
|
||||
|
||||
|
||||
def mid(v, default):
|
||||
"""'100-120' -> 110 (mihomo takes single ints for timers)."""
|
||||
if v is None or v == "":
|
||||
return default
|
||||
m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", str(v))
|
||||
return (int(m.group(1)) + int(m.group(2))) // 2 if m else int(v)
|
||||
|
||||
|
||||
def amnezia(path, name):
|
||||
key = open(path).read().strip()[len("vpn://"):]
|
||||
key += "=" * (-len(key) % 4)
|
||||
j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:]))
|
||||
awg = j["containers"][0]["awg"]
|
||||
c = awg["last_config"] if isinstance(awg["last_config"], dict) else json.loads(awg["last_config"])
|
||||
ver = 3 if c.get("HeaderProtectionKey") else 2
|
||||
opt = {"version": ver, "jc": int(c["Jc"]), "jmin": int(c["Jmin"]), "jmax": int(c["Jmax"]),
|
||||
"s1": int(c["S1"]), "s2": int(c["S2"])}
|
||||
for k in ("S3", "S4"):
|
||||
if c.get(k):
|
||||
opt[k.lower()] = int(c[k])
|
||||
for k in ("H1", "H2", "H3", "H4"):
|
||||
v = c[k]
|
||||
opt[k.lower()] = int(v) if str(v).isdigit() else v
|
||||
for k in ("I1", "I2", "I3", "I4", "I5"):
|
||||
if c.get(k):
|
||||
opt[k.lower()] = c[k]
|
||||
if ver == 3:
|
||||
opt.update({
|
||||
"header-protection-key": c["HeaderProtectionKey"],
|
||||
"content-padding-addition": c.get("ContentPaddingAddition", "0"),
|
||||
"rekey-after-time": mid(c.get("RekeyAfterTime"), 120),
|
||||
"rekey-timeout": mid(c.get("RekeyTimeout"), 5),
|
||||
"reject-after-time": mid(c.get("RejectAfterTime"), 180),
|
||||
"keepalive-timeout": mid(c.get("KeepaliveTimeout"), 10),
|
||||
"max-handshake-attempts": mid(c.get("MaxHandshakeAttempts"), 18),
|
||||
"random-trailers": c.get("RandomTrailers") == "on",
|
||||
"disable-cookies": c.get("DisableCookies") == "on",
|
||||
})
|
||||
return {
|
||||
"name": name, "type": "wireguard", "server": c["hostName"], "port": int(c["port"]),
|
||||
"ip": c["client_ip"], "private-key": c["client_priv_key"], "public-key": c["server_pub_key"],
|
||||
"pre-shared-key": c.get("psk_key") or None, "mtu": int(c.get("mtu", 1376)), "udp": True,
|
||||
"persistent-keepalive": mid(c.get("persistent_keep_alive"), 25),
|
||||
"amnezia-wg-option": opt,
|
||||
}
|
||||
|
||||
|
||||
def wg(name, path):
|
||||
p = configparser.ConfigParser()
|
||||
p.optionxform = str
|
||||
p.read(os.path.join(SRC, path))
|
||||
i, peer = p["Interface"], p["Peer"]
|
||||
host, port = peer["Endpoint"].rsplit(":", 1)
|
||||
out = {"name": name, "type": "wireguard", "server": host, "port": int(port),
|
||||
"ip": i["Address"].split("/")[0], "private-key": i["PrivateKey"],
|
||||
"public-key": peer["PublicKey"], "mtu": int(i.get("MTU", 1420)), "udp": True}
|
||||
if peer.get("PresharedKey"):
|
||||
out["pre-shared-key"] = peer["PresharedKey"]
|
||||
if peer.get("PersistentKeepalive"):
|
||||
out["persistent-keepalive"] = int(peer["PersistentKeepalive"])
|
||||
return out
|
||||
|
||||
|
||||
def y(v, ind=0):
|
||||
"""Tiny YAML emitter (no PyYAML dependency)."""
|
||||
pad = " " * ind
|
||||
if isinstance(v, dict):
|
||||
lines = []
|
||||
for k, x in v.items():
|
||||
if x is None:
|
||||
continue
|
||||
if isinstance(x, (dict, list)) and x:
|
||||
lines.append(f"{pad}{k}:\n{y(x, ind + 1)}")
|
||||
else:
|
||||
lines.append(f"{pad}{k}: {scalar(x)}")
|
||||
return "\n".join(lines)
|
||||
if isinstance(v, list):
|
||||
lines = []
|
||||
for x in v:
|
||||
if isinstance(x, dict):
|
||||
body = y(x, ind + 1).lstrip()
|
||||
lines.append(f"{pad}- {body}")
|
||||
else:
|
||||
lines.append(f"{pad}- {scalar(x)}")
|
||||
return "\n".join(lines)
|
||||
return pad + scalar(v)
|
||||
|
||||
|
||||
def scalar(x):
|
||||
if isinstance(x, bool):
|
||||
return "true" if x else "false"
|
||||
if isinstance(x, (int, float)):
|
||||
return str(x)
|
||||
if isinstance(x, list) and not x:
|
||||
return "[]"
|
||||
return json.dumps(str(x), ensure_ascii=False)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(CORE, exist_ok=True)
|
||||
secret_file = os.path.join(CORE, "api.secret")
|
||||
if not os.path.exists(secret_file):
|
||||
fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(secrets.token_urlsafe(24))
|
||||
api_secret = open(secret_file).read().strip()
|
||||
|
||||
# Amnezia connections live in their own provider file so BroV can add keys live.
|
||||
keyfiles = sorted(f for f in os.listdir(KEYS) if f.endswith(".vpnkey"))
|
||||
awg = [amnezia(os.path.join(KEYS, f), "AWG " + f[:-len(".vpnkey")]) for f in keyfiles]
|
||||
prov = os.path.join(KEYS, "amnezia.yaml")
|
||||
with open(os.open(prov, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
||||
f.write(y({"proxies": awg}) + "\n")
|
||||
proxies = [wg(n, f) for n, (f, _) in CLIENTS.items()]
|
||||
sub = open(os.path.join(SRC, "vless.sub")).read().strip()
|
||||
|
||||
rules = [f"IP-CIDR,{HOME_NET},DIRECT,no-resolve", "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
|
||||
# Home's own public IP (RustDesk, Gitea): never via a foreign exit.
|
||||
"IP-CIDR,79.111.14.0/32,DIRECT,no-resolve", "DOMAIN-SUFFIX,sanjeev.ru,DIRECT"]
|
||||
for name, (_, nets) in CLIENTS.items():
|
||||
# Through a switch group: BroV turns client networks on/off without a reload.
|
||||
rules += [f"IP-CIDR,{n},{name}-sw,no-resolve" for n in nets]
|
||||
rules += [f"DOMAIN-SUFFIX,{d},ai-out" for d in AI_DOMAINS]
|
||||
rules += ["DOMAIN-SUFFIX,ru,DIRECT", "DOMAIN-SUFFIX,su,DIRECT", "DOMAIN-SUFFIX,xn--p1ai,DIRECT",
|
||||
"MATCH,ai-out"]
|
||||
|
||||
cfg = {
|
||||
"mixed-port": 7890, "allow-lan": False, "mode": "rule", "log-level": "error", "ipv6": False,
|
||||
"external-controller": "127.0.0.1:9097", "secret": api_secret, "unified-delay": True,
|
||||
"find-process-mode": "strict",
|
||||
"profile": {"store-selected": True},
|
||||
"tun": {"enable": True, "stack": "mixed", "auto-route": True, "auto-detect-interface": True,
|
||||
"dns-hijack": ["any:53"], "mtu": 1400},
|
||||
"dns": {"enable": True, "ipv6": False, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16",
|
||||
"fake-ip-filter": ["*.lan", "*.local", "+.duckdns.org"],
|
||||
"default-nameserver": ["77.88.8.8", "1.1.1.1"],
|
||||
"proxy-server-nameserver": ["77.88.8.8", "1.1.1.1"],
|
||||
"nameserver": ["https://1.1.1.1/dns-query#ai-out", "https://8.8.8.8/dns-query#ai-out"],
|
||||
"direct-nameserver": ["77.88.8.8", "77.88.8.1"]},
|
||||
"proxies": proxies,
|
||||
"proxy-providers": {
|
||||
"amnezia-keys": {"type": "file", "path": "./keys/amnezia.yaml",
|
||||
"health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204",
|
||||
"interval": 300}},
|
||||
"vless-cluster": {
|
||||
# Fetch the list directly: the nodes themselves are dialled directly anyway.
|
||||
"type": "http", "url": sub, "interval": 43200, "path": "./providers/vless.yaml", "proxy": "DIRECT",
|
||||
"health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204", "interval": 300}}},
|
||||
"proxy-groups": [
|
||||
# What BroV's globe panel switches: "auto", the Amnezia group, or one VLESS node.
|
||||
{"name": "ai-out", "type": "select", "proxies": ["auto", "amnezia"], "use": ["vless-cluster"]},
|
||||
# Fastest alive exit among every Amnezia connection and every VLESS node;
|
||||
# switches only when another one is 100+ ms faster.
|
||||
{"name": "auto", "type": "url-test", "use": ["amnezia-keys", "vless-cluster"],
|
||||
"url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False},
|
||||
# Amnezia: "amnezia-auto" (fastest connection) or one fixed connection.
|
||||
{"name": "amnezia", "type": "select", "proxies": ["amnezia-auto"], "use": ["amnezia-keys"]},
|
||||
{"name": "amnezia-auto", "type": "url-test", "use": ["amnezia-keys"],
|
||||
"url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False},
|
||||
] + [
|
||||
# Client networks: off (REJECT) until switched on in BroV.
|
||||
{"name": f"{n}-sw", "type": "select", "proxies": ["REJECT", n]} for n in CLIENTS
|
||||
],
|
||||
"rules": rules,
|
||||
}
|
||||
# The API secret never leaves this root-only folder: BroV goes through netctl.py.
|
||||
path = os.path.join(CORE, "config.yaml")
|
||||
with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
||||
f.write("# Generated by gen.py — do not edit by hand, do not share.\n" + y(cfg) + "\n")
|
||||
print("wrote", path, "|", len(proxies), "proxies + vless subscription |", len(rules), "rules")
|
||||
|
||||
|
||||
def check(path):
|
||||
"""--check <file>: is this a usable Amnezia key? Prints JSON for BroV."""
|
||||
try:
|
||||
raw = open(path).read().strip()
|
||||
if not raw.startswith("vpn://"):
|
||||
raise ValueError("ключ должен начинаться с vpn://")
|
||||
key = raw[len("vpn://"):]
|
||||
key += "=" * (-len(key) % 4)
|
||||
j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:]))
|
||||
cont = j["containers"][0]
|
||||
if "awg" not in cont:
|
||||
raise ValueError("это не AmneziaWG (контейнер %s) — пока поддерживается только AmneziaWG" % cont.get("container"))
|
||||
p = amnezia(path, "check")
|
||||
print(json.dumps({"ok": True, "name": j.get("description") or p["server"], "server": p["server"],
|
||||
"port": p["port"], "version": p["amnezia-wg-option"]["version"]}, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(json.dumps({"ok": False, "error": str(e) or e.__class__.__name__}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--check":
|
||||
check(sys.argv[2])
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# BroV network core — install / update as root services (run by hand, once per update):
|
||||
# sudo sh scripts/netcore/install.sh
|
||||
#
|
||||
# Layout after install (everything root-owned, nothing a user process can change):
|
||||
# /Library/Application Support/BroV/mihomo the core, checked against the release SHA256
|
||||
# /Library/Application Support/BroV/netctl.py narrow helper BroV talks to
|
||||
# /Library/Application Support/BroV/netcore/ config, gen.py, keys, API secret (0700)
|
||||
# /Library/LaunchDaemons/local.maksar.brov.netd.plist the core, at boot, restarted on crash
|
||||
# /Library/LaunchDaemons/local.maksar.brov.netctl.plist the helper (socket /var/run/brov-netctl.sock,
|
||||
# only your user may connect)
|
||||
# First run migrates keys from ~/Library/Application Support/NotchBuddy/netcore and then
|
||||
# deletes that user-writable copy (it is what made root trust user files).
|
||||
#
|
||||
# Undo: sudo sh scripts/netcore/uninstall.sh
|
||||
|
||||
set -e
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; }
|
||||
USER_NAME="${SUDO_USER:?запусти через sudo из своей учётной записи}"
|
||||
USER_UID=$(id -u "$USER_NAME")
|
||||
USER_HOME=$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory | awk '{print $2}')
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT="/Library/Application Support/BroV"
|
||||
CORE="$ROOT/netcore"
|
||||
OLD="$USER_HOME/Library/Application Support/NotchBuddy/netcore"
|
||||
BIN="$ROOT/mihomo"
|
||||
CORE_LABEL="local.maksar.brov.netd"
|
||||
CTL_LABEL="local.maksar.brov.netctl"
|
||||
|
||||
MIHOMO_VERSION="v1.19.32"
|
||||
MIHOMO_GZ_SHA="3312a6780652c622890fd4357c6a853bbf865464fd047ac7b7f52dab8de18652"
|
||||
MIHOMO_BIN_SHA="94a386ec0149080deadd86b1f667363bde3c70f7489dba3258e52c56fc9a6d66"
|
||||
|
||||
if pgrep -qx AmneziaVPN; then
|
||||
echo "Приложение AmneziaVPN запущено — закрой его (только приложение, служба не мешает)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
install -d -m 755 -o root -g wheel "$ROOT"
|
||||
install -d -m 700 -o root -g wheel "$CORE" "$CORE/keys" "$CORE/src" "$CORE/providers"
|
||||
install -d -m 755 -o root -g wheel /Library/Logs/BroV
|
||||
|
||||
# 1. The core binary: keep the installed one if it matches, else fetch and verify.
|
||||
if [ "$(shasum -a 256 "$BIN" 2>/dev/null | cut -d' ' -f1)" = "$MIHOMO_BIN_SHA" ]; then
|
||||
echo "✓ mihomo $MIHOMO_VERSION на месте, контрольная сумма совпадает"
|
||||
else
|
||||
echo "→ Скачиваю mihomo $MIHOMO_VERSION с GitHub и проверяю SHA256"
|
||||
TMP=$(mktemp -d)
|
||||
curl -fsSL -o "$TMP/m.gz" "https://github.com/MetaCubeX/mihomo/releases/download/$MIHOMO_VERSION/mihomo-darwin-arm64-$MIHOMO_VERSION.gz"
|
||||
[ "$(shasum -a 256 "$TMP/m.gz" | cut -d' ' -f1)" = "$MIHOMO_GZ_SHA" ] || { echo "✗ архив не совпал с официальной суммой"; rm -rf "$TMP"; exit 1; }
|
||||
gunzip -c "$TMP/m.gz" > "$TMP/mihomo"
|
||||
install -m 755 -o root -g wheel "$TMP/mihomo" "$BIN"
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
|
||||
# 2. Helper scripts from the repo (no secrets in them).
|
||||
install -m 700 -o root -g wheel "$HERE/gen.py" "$CORE/gen.py"
|
||||
install -m 755 -o root -g wheel "$HERE/netctl.py" "$ROOT/netctl.py"
|
||||
|
||||
# 3. Migrate secrets from the old user folder — regular files only, never symlinks.
|
||||
copy_regular() { # src dst
|
||||
[ -f "$1" ] && [ ! -L "$1" ] && install -m 600 -o root -g wheel "$1" "$2"
|
||||
return 0
|
||||
}
|
||||
if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then
|
||||
echo "→ Переношу ключи и настройки в $CORE"
|
||||
for f in "$OLD"/keys/*.vpnkey; do copy_regular "$f" "$CORE/keys/$(basename "$f")"; done
|
||||
for f in "$OLD"/src/*; do copy_regular "$f" "$CORE/src/$(basename "$f")"; done
|
||||
[ -f "$CORE/api.secret" ] || copy_regular "$OLD/api.secret" "$CORE/api.secret"
|
||||
[ -f "$CORE/cache.db" ] || copy_regular "$OLD/cache.db" "$CORE/cache.db"
|
||||
copy_regular "$OLD/providers/vless.yaml" "$CORE/providers/vless.yaml"
|
||||
fi
|
||||
ls "$CORE"/keys/*.vpnkey >/dev/null 2>&1 || { echo "✗ нет ни одного ключа Амнезии в $CORE/keys"; exit 1; }
|
||||
for f in saga.conf planet9.conf vless.sub; do
|
||||
[ -f "$CORE/src/$f" ] || { echo "✗ нет $CORE/src/$f"; exit 1; }
|
||||
done
|
||||
chown -R root:wheel "$CORE"
|
||||
chmod -R go-rwx "$CORE"
|
||||
|
||||
# 4. Build and check the config as root.
|
||||
echo "→ Собираю конфиг"
|
||||
(cd "$CORE" && /usr/bin/python3 gen.py)
|
||||
"$BIN" -t -d "$CORE" -f "$CORE/config.yaml" >/dev/null
|
||||
: > /Library/Logs/BroV/netcore.log
|
||||
chmod 600 /Library/Logs/BroV/netcore.log
|
||||
|
||||
# 5. Services.
|
||||
write_plist() { # label, then program arguments
|
||||
label=$1; shift
|
||||
{
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
echo '<plist version="1.0"><dict>'
|
||||
echo " <key>Label</key><string>$label</string>"
|
||||
echo ' <key>ProgramArguments</key><array>'
|
||||
for a in "$@"; do echo " <string>$a</string>"; done
|
||||
echo ' </array>'
|
||||
echo ' <key>RunAtLoad</key><true/>'
|
||||
echo ' <key>KeepAlive</key><true/>'
|
||||
echo ' <key>ThrottleInterval</key><integer>5</integer>'
|
||||
echo " <key>StandardOutPath</key><string>/Library/Logs/BroV/$label.log</string>"
|
||||
echo " <key>StandardErrorPath</key><string>/Library/Logs/BroV/$label.log</string>"
|
||||
echo '</dict></plist>'
|
||||
} > "/Library/LaunchDaemons/$label.plist"
|
||||
chown root:wheel "/Library/LaunchDaemons/$label.plist"
|
||||
chmod 644 "/Library/LaunchDaemons/$label.plist"
|
||||
plutil -lint "/Library/LaunchDaemons/$label.plist" >/dev/null
|
||||
}
|
||||
write_plist "$CORE_LABEL" "$BIN" -d "$CORE" -f "$CORE/config.yaml"
|
||||
write_plist "$CTL_LABEL" /usr/bin/python3 "$ROOT/netctl.py" "$USER_UID"
|
||||
rm -f /Library/Logs/BroV/netcore.log
|
||||
|
||||
echo "→ Запускаю службы"
|
||||
for label in "$CORE_LABEL" "$CTL_LABEL"; do
|
||||
launchctl bootout "system/$label" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
for label in "$CORE_LABEL" "$CTL_LABEL"; do
|
||||
launchctl bootstrap system "/Library/LaunchDaemons/$label.plist" 2>/dev/null || launchctl kickstart -k "system/$label"
|
||||
done
|
||||
|
||||
i=0
|
||||
until [ -S /var/run/brov-netctl.sock ] || [ $i -ge 20 ]; do sleep 0.5; i=$((i+1)); done
|
||||
if launchctl print "system/$CORE_LABEL" | grep -q 'state = running' && [ -S /var/run/brov-netctl.sock ]; then
|
||||
echo "✓ Ядро и помощник работают."
|
||||
else
|
||||
echo "⚠ Что-то не поднялось. Логи: /Library/Logs/BroV/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. Remove the old user-writable copy (keys, secret, binary) — root no longer reads it.
|
||||
if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then
|
||||
rm -rf "$OLD"
|
||||
echo "✓ Старая копия ключей в ~/Library/Application Support/NotchBuddy/netcore удалена"
|
||||
fi
|
||||
rm -f "$USER_HOME/Library/Application Support/NotchBuddy/netcore.json"
|
||||
echo "Готово. Управление — глобус 🌐 в чёлке BroV."
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/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, name must be a member
|
||||
delay {group}|{proxy} speed test of ai-out / amnezia, or of the saga / planet9 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"}
|
||||
DELAY_GROUPS = {"ai-out", "amnezia"}
|
||||
CLIENT_TUNNELS = {"saga", "planet9"}
|
||||
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()
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Removes the BroV network core services (sudo sh scripts/netcore/uninstall.sh).
|
||||
# Keys stay in /Library/Application Support/BroV/netcore unless you pass --purge.
|
||||
# After this the Mac goes online by itself; turn AmneziaVPN back on if needed.
|
||||
set -e
|
||||
[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; }
|
||||
for label in local.maksar.brov.netctl local.maksar.brov.netd; do
|
||||
launchctl bootout "system/$label" 2>/dev/null || true
|
||||
rm -f "/Library/LaunchDaemons/$label.plist"
|
||||
done
|
||||
rm -f /var/run/brov-netctl.sock
|
||||
if [ "$1" = "--purge" ]; then
|
||||
rm -rf "/Library/Application Support/BroV" /Library/Logs/BroV
|
||||
echo "✓ Службы, ядро и ключи удалены."
|
||||
else
|
||||
echo "✓ Службы ядра BroV остановлены и удалены. Ключи остались в /Library/Application Support/BroV/netcore (root)."
|
||||
fi
|
||||
Reference in New Issue
Block a user