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:
maksarsanjeev
2026-10-07 23:49:11 +03:00
parent dc47247340
commit 52e1e8288b
21 changed files with 1070 additions and 188 deletions
+226
View File
@@ -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()
+140
View File
@@ -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."
+235
View File
@@ -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()
+17
View File
@@ -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