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
+81 -107
View File
@@ -3,8 +3,8 @@ import AppKit
// MARK: - Networks (globe in the header)
//
// BroV is the remote for the network core (mihomo). It talks to the core's local REST
// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json):
// BroV is the remote for the network core (mihomo, root). It never touches the core's
// config or API secret: every action goes through the narrow root helper netctl.py:
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
// • right column — client networks: groups "<client>-sw" switched between REJECT and
// the client's WireGuard tunnel.
@@ -45,64 +45,87 @@ final class NetCore: ObservableObject {
/// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:]
private var base = ""
private var secret = ""
static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"),
]
private func loadEndpoint() -> Bool {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json")
guard let data = try? Data(contentsOf: url),
let j = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let c = j["controller"], let s = j["secret"] else { return false }
base = c; secret = s
return true
// MARK: Root helper (netctl.py)
//
// The core, its config, keys and API secret are root-only. BroV only talks to the
// narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
// delay, tun, add_key, remove_key — nothing that could rewrite the core config.
private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
private func call(_ req: [String: Any], timeout: Int = 12) async -> [String: Any]? {
guard let body = try? JSONSerialization.data(withJSONObject: req) else { return nil }
// Raw bytes cross threads (Sendable); JSON is parsed back here.
let reply: Data? = await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
cont.resume(returning: Self.callSync(body, timeout: timeout))
}
}
guard let reply else { return nil }
return try? JSONSerialization.jsonObject(with: reply) as? [String: Any]
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
timeout: TimeInterval = 4) async -> Any? {
guard !base.isEmpty || loadEndpoint(),
let url = URL(string: base + path) else { return nil }
var r = URLRequest(url: url, timeoutInterval: timeout)
r.httpMethod = method
r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization")
if let body {
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.httpBody = try? JSONSerialization.data(withJSONObject: body)
private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var tv = timeval(tv_sec: timeout, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
}
guard let (data, resp) = try? await URLSession.shared.data(for: r),
let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:]
let connected = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { return nil }
var data = body
data.append(0x0A)
let sent = data.withUnsafeBytes { send(fd, $0.baseAddress, data.count, 0) }
guard sent == data.count else { return nil }
var out = Data()
var buf = [UInt8](repeating: 0, count: 65536)
while !out.contains(0x0A) {
let n = recv(fd, &buf, buf.count, 0)
if n <= 0 { break }
out.append(contentsOf: buf[0..<n])
}
guard let line = out.split(separator: 0x0A).first else { return nil }
return Data(line)
}
/// Reads groups and last known delays.
func refresh() async {
guard let all = await request("/proxies") as? [String: Any],
let proxies = all["proxies"] as? [String: [String: Any]] else {
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
let proxies = st["proxies"] as? [String: [String: Any]] else {
running = false
return
}
running = true
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
tunOn = tun["enable"] as? Bool ?? false
}
tunOn = st["tun"] as? Bool ?? false
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies.
var providerDelay: [String: Int] = [:]
var awgNames: [String] = []
for prov in ["vless-cluster", "amnezia-keys"] {
guard let p = await request("/providers/proxies/\(prov)") as? [String: Any],
let list = p["proxies"] as? [[String: Any]] else { continue }
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
for (prov, list) in providers {
for x in list {
guard let n = x["name"] as? String else { continue }
if prov == "amnezia-keys" { awgNames.append(n) }
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
}
}
awgNames.sort()
func lastDelay(_ name: String) -> Int? {
if let d = measured[name] { return d }
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
@@ -141,108 +164,63 @@ final class NetCore: ObservableObject {
/// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async {
busy = true
let test = "url=https://www.gstatic.com/generate_204&timeout=5000"
if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] as? [String: Int] {
measured = m
// Members that didn't answer are missing from the map: mark them dead.
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
}
if let m = await request("/group/amnezia/delay?\(test)", timeout: 8) as? [String: Int] {
if let m = (await call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
}
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
for c in clients where c.on {
if let m = (await call(["cmd": "delay", "proxy": c.id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
}
await refresh()
busy = false
}
func select(exit name: String) async {
currentExit = name
_ = await request("/proxies/ai-out", method: "PUT", body: ["name": name])
_ = await call(["cmd": "select", "group": "ai-out", "name": name])
SoundEngine.shared.play("blip")
await refresh()
}
func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
_ = await request("/proxies/\(id)-sw", method: "PUT", body: ["name": on ? id : "REJECT"])
_ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : "REJECT"])
SoundEngine.shared.play(on ? "pop" : "close")
if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) }
if on, let m = (await call(["cmd": "delay", "proxy": id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
await refresh()
}
func select(amnezia name: String) async {
amneziaNow = name
_ = await request("/proxies/amnezia", method: "PUT", body: ["name": name])
if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) }
_ = await call(["cmd": "select", "group": "amnezia", "name": name])
if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
SoundEngine.shared.play("blip")
await refresh()
}
static var coreDir: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore")
}
/// Runs gen.py (the single converter for keys → core config) in the core folder.
private nonisolated static func gen(_ args: [String]) async -> String {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
p.arguments = ["gen.py"] + args
p.currentDirectoryURL = coreDir
let out = Pipe()
p.standardOutput = out
p.standardError = out
guard (try? p.run()) != nil else { cont.resume(returning: ""); return }
let data = out.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
}
}
}
/// Checks a pasted vpn:// key, stores it, rebuilds the Amnezia provider and tests it.
/// Returns a message for the add panel.
/// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
let keys = Self.coreDir.appendingPathComponent("keys")
let pending = keys.appendingPathComponent(".pending.vpnkey")
do {
try key.write(to: pending, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
} catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
let out = await Self.gen(["--check", pending.path])
guard let line = out.split(separator: "\n").last,
let j = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
j["ok"] as? Bool == true else {
try? FileManager.default.removeItem(at: pending)
let err = ((try? JSONSerialization.jsonObject(with: Data((out.split(separator: "\n").last ?? "").utf8))) as? [String: Any])?["error"] as? String
return (false, "Ключ не подходит: \(err ?? "не удалось разобрать")")
guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
return (false, "Помощник сетевого ядра не отвечает.")
}
let name = (j["name"] as? String) ?? "amnezia"
let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)"
// File name from the connection name; never overwrite an existing one.
var slug = name.lowercased().map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined()
if slug.isEmpty { slug = "amnezia" }
var dest = keys.appendingPathComponent("\(slug).vpnkey")
var n = 2
while FileManager.default.fileExists(atPath: dest.path) {
dest = keys.appendingPathComponent("\(slug)-\(n).vpnkey"); n += 1
guard r["ok"] as? Bool == true else {
return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
}
do { try FileManager.default.moveItem(at: pending, to: dest) }
catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
_ = await Self.gen([])
// Live: re-read only the Amnezia provider, the core keeps running.
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
let proxy = "AWG " + dest.deletingPathExtension().lastPathComponent
let enc = proxy.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? proxy
let hc = await request("/providers/proxies/amnezia-keys/\(enc)/healthcheck?url=https://www.gstatic.com/generate_204&timeout=6000",
timeout: 10) as? [String: Any]
await refresh()
if let d = hc?["delay"] as? Int, d > 0 {
let name = r["name"] as? String ?? "?"
let server = r["server"] as? String ?? "?"
if let d = r["delay"] as? Int, d > 0 {
SoundEngine.shared.play("finish")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
}
@@ -250,11 +228,7 @@ final class NetCore: ObservableObject {
}
func removeAmnezia(_ proxyName: String) async {
let file = String(proxyName.dropFirst(4)) + ".vpnkey"
try? FileManager.default.removeItem(at: Self.coreDir.appendingPathComponent("keys").appendingPathComponent(file))
if amneziaNow == proxyName { _ = await request("/proxies/amnezia", method: "PUT", body: ["name": "amnezia-auto"]) }
_ = await Self.gen([])
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
_ = await call(["cmd": "remove_key", "name": proxyName])
SoundEngine.shared.play("close")
await refresh()
}
@@ -272,7 +246,7 @@ final class NetCore: ObservableObject {
}
lastError = nil
tunOn = on
_ = await request("/configs", method: "PATCH", body: ["tun": ["enable": on]], timeout: 8)
_ = await call(["cmd": "tun", "on": on])
SoundEngine.shared.play(on ? "pop" : "close")
try? await Task.sleep(for: .seconds(1))
await refresh()
@@ -359,7 +333,7 @@ struct NetPanel: View {
}
if !net.running {
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/brov-secrets/install-netd.sh\nДальше ядро будет запускаться само при включении Мака.")
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/work/macbookbrov/brov/scripts/netcore/install.sh\nДальше ядро будет запускаться само при включении Мака.")
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled)