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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user