diff --git a/ApplicationLibrary/Views/Setting/CoreView.swift b/ApplicationLibrary/Views/Setting/CoreView.swift index 4fa6676..bc2afc6 100644 --- a/ApplicationLibrary/Views/Setting/CoreView.swift +++ b/ApplicationLibrary/Views/Setting/CoreView.swift @@ -175,7 +175,7 @@ public struct CoreView: View { self.helperUnavailable = helperUnavailable #endif self.dataSize = dataSize - self.dataSizeLoaded = true + dataSizeLoaded = true } } diff --git a/HelperService/RootHelperService.swift b/HelperService/RootHelperService.swift index 2069c64..4dbead3 100644 --- a/HelperService/RootHelperService.swift +++ b/HelperService/RootHelperService.swift @@ -1,11 +1,32 @@ import Foundation +import Libbox import Library +import Network import os private let logger = Logger(category: "RootHelper") +private class NeighborGoListener: NSObject, LibboxNeighborUpdateListenerProtocol { + private weak var service: RootHelperService? + + init(service: RootHelperService) { + self.service = service + } + + func updateNeighborTable(_ entries: (any LibboxNeighborEntryIteratorProtocol)?) { + guard let entries, let service else { return } + service.pushNeighborTable(entries: entries) + } +} + class RootHelperService: NSObject { private var listener: NSXPCListener? + private var neighborSubscription: LibboxNeighborSubscription? + private var neighborCallbackConnection: NSXPCConnection? + private var neighborLeaseWatcher: DispatchSourceFileSystemObject? + private var pathMonitor: NWPathMonitor? + private var pendingNATFlush: DispatchWorkItem? + private var tunInterfaceName: String? func start() { listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService) @@ -86,4 +107,249 @@ extension RootHelperService: RootHelperProtocol { func getVersion(reply: @escaping (String) -> Void) { reply(Bundle.main.version) } + + func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) { + logger.info("startNeighborMonitor") + closeNeighborMonitorInternal() + + let callbackConnection = NSXPCConnection(listenerEndpoint: callbackEndpoint) + let listenerInterface = NSXPCInterface(with: NeighborTableListenerProtocol.self) + RootHelperXPC.configureListenerInterface(listenerInterface) + callbackConnection.remoteObjectInterface = listenerInterface + callbackConnection.resume() + neighborCallbackConnection = callbackConnection + + let goListener = NeighborGoListener(service: self) + var error: NSError? + let subscription = LibboxSubscribeNeighborTable(goListener, &error) + if let error { + logger.error("startNeighborMonitor: \(error.localizedDescription)") + callbackConnection.invalidate() + neighborCallbackConnection = nil + reply(error) + return + } + neighborSubscription = subscription + startLeaseFileWatcher() + startNATCleaner() + reply(nil) + } + + func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) { + logger.info("registerMyInterface: \(name)") + tunInterfaceName = name + flushInternetSharingNAT() + reply(nil) + } + + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) { + logger.info("closeNeighborMonitor") + closeNeighborMonitorInternal() + reply(nil) + } + + private func closeNeighborMonitorInternal() { + neighborSubscription?.close() + neighborSubscription = nil + neighborLeaseWatcher?.cancel() + neighborLeaseWatcher = nil + pendingNATFlush?.cancel() + pendingNATFlush = nil + pathMonitor?.cancel() + pathMonitor = nil + tunInterfaceName = nil + neighborCallbackConnection?.invalidate() + neighborCallbackConnection = nil + } + + func pushNeighborTable(entries: LibboxNeighborEntryIteratorProtocol) { + guard let callbackConnection = neighborCallbackConnection else { + logger.warning("pushNeighborTable: no callback connection") + return + } + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in + logger.error("pushNeighborTable XPC error: \(error.localizedDescription)") + }) as? NeighborTableListenerProtocol else { + logger.warning("pushNeighborTable: failed to get proxy") + return + } + + let leaseIterator = LibboxReadBootpdLeases() + var leaseEntries: [NeighborEntryResult] = [] + var leaseHostnamesByMAC: [String: String] = [:] + var leaseHostnamesByIP: [String: String] = [:] + if let leaseIterator { + while leaseIterator.hasNext() { + guard let entry = leaseIterator.next() else { continue } + leaseEntries.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: entry.hostname + )) + if !entry.hostname.isEmpty { + leaseHostnamesByMAC[entry.macAddress] = entry.hostname + leaseHostnamesByIP[entry.address] = entry.hostname + } + } + } + logger.debug("pushNeighborTable: leases=\(leaseEntries.count), hostnames=\(leaseHostnamesByMAC.count)") + + var results: [NeighborEntryResult] = [] + var seenAddresses: Set = [] + while entries.hasNext() { + guard let entry = entries.next() else { continue } + seenAddresses.insert(entry.address) + var hostname = entry.hostname + if hostname.isEmpty { + hostname = leaseHostnamesByIP[entry.address] ?? leaseHostnamesByMAC[entry.macAddress] ?? "" + } + results.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: hostname + )) + } + for leaseEntry in leaseEntries { + if !seenAddresses.contains(leaseEntry.address) { + results.append(leaseEntry) + } + } + logger.debug("pushNeighborTable: \(results.count) entries") + proxy.updateNeighborTable(entries: results as NSArray) + } + + private func startNATCleaner() { + flushInternetSharingNAT() + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "nat-cleaner") + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + logger.debug("NATCleaner: path update, status=\(String(describing: path.status)), interfaces=\(path.availableInterfaces.map(\.name))") + self.pendingNATFlush?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.flushInternetSharingNAT() + } + self.pendingNATFlush = workItem + queue.asyncAfter(deadline: .now() + 2, execute: workItem) + } + monitor.start(queue: queue) + pathMonitor = monitor + } + + private func flushInternetSharingNAT() { + guard let tunName = tunInterfaceName, !tunName.isEmpty else { + logger.debug("flushInternetSharingNAT: no tun interface name set") + return + } + let anchors = [ + "com.apple.internet-sharing/shared_v4", + "com.apple.internet-sharing/shared_v6", + ] + let filter = " on \(tunName) " + for anchor in anchors { + removeNATRulesForInterface(anchor: anchor, filter: filter) + } + } + + private func removeNATRulesForInterface(anchor: String, filter: String) { + let readProcess = Process() + readProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + readProcess.arguments = ["-a", anchor, "-s", "nat"] + let readPipe = Pipe() + readProcess.standardOutput = readPipe + readProcess.standardError = FileHandle.nullDevice + do { + try readProcess.run() + } catch { + logger.error("removeNATRules: failed to read \(anchor): \(error.localizedDescription)") + return + } + let output = String(data: readPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + readProcess.waitUntilExit() + if readProcess.terminationStatus != 0 { + logger.warning("removeNATRules: pfctl -s nat exited with \(readProcess.terminationStatus) for \(anchor)") + return + } + if output.isEmpty { + logger.debug("removeNATRules: \(anchor) has no NAT rules") + return + } + guard output.contains(filter) else { + logger.debug("removeNATRules: \(anchor) has no rules matching \(filter)") + return + } + let lines = output.components(separatedBy: "\n") + let removed = lines.filter { $0.contains(filter) } + let remaining = lines.filter { !$0.contains(filter) }.joined(separator: "\n") + logger.info("removeNATRules: \(anchor): removing \(removed.count) rules matching \(filter), keeping \(lines.count - removed.count) rules") + for rule in removed { + logger.debug("removeNATRules: removing: \(rule)") + } + let writeProcess = Process() + writeProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + writeProcess.arguments = ["-a", anchor, "-N", "-f", "-"] + let writePipe = Pipe() + writePipe.fileHandleForWriting.write(remaining.data(using: .utf8) ?? Data()) + writePipe.fileHandleForWriting.closeFile() + writeProcess.standardInput = writePipe + writeProcess.standardOutput = FileHandle.nullDevice + let writeErrorPipe = Pipe() + writeProcess.standardError = writeErrorPipe + do { + try writeProcess.run() + } catch { + logger.error("removeNATRules: failed to write \(anchor): \(error.localizedDescription)") + return + } + let stderrData = writeErrorPipe.fileHandleForReading.readDataToEndOfFile() + writeProcess.waitUntilExit() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + if writeProcess.terminationStatus != 0 { + logger.error("removeNATRules: pfctl -f exited with \(writeProcess.terminationStatus) for \(anchor), stderr: \(stderrOutput)") + } else { + logger.debug("removeNATRules: successfully updated \(anchor)") + } + } + + private func startLeaseFileWatcher() { + let leasePath = "/var/db/dhcpd_leases" + let fd = open(leasePath, O_EVTONLY) + guard fd >= 0 else { + logger.warning("startLeaseFileWatcher: failed to open \(leasePath), errno=\(errno)") + return + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename], + queue: DispatchQueue.global() + ) + source.setEventHandler { [weak self] in + guard let self, neighborSubscription != nil else { return } + guard let callbackConnection = neighborCallbackConnection else { return } + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in + logger.error("leaseWatcher push error: \(error.localizedDescription)") + }) as? NeighborTableListenerProtocol else { + return + } + + let leaseIterator = LibboxReadBootpdLeases() + var results: [NeighborEntryResult] = [] + if let leaseIterator { + while leaseIterator.hasNext() { + guard let entry = leaseIterator.next() else { continue } + results.append(NeighborEntryResult( + address: entry.address, + macAddress: entry.macAddress, + hostname: entry.hostname + )) + } + } + proxy.updateNeighborTable(entries: results as NSArray) + } + source.setCancelHandler { + close(fd) + } + source.resume() + neighborLeaseWatcher = source + } } diff --git a/Library/Network/ExtensionPlatformInterface.swift b/Library/Network/ExtensionPlatformInterface.swift index cbb3797..43c40c0 100644 --- a/Library/Network/ExtensionPlatformInterface.swift +++ b/Library/Network/ExtensionPlatformInterface.swift @@ -460,6 +460,11 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc networkSettings = nil nwMonitor?.cancel() nwMonitor = nil + #if os(macOS) + neighborCallbackListener?.invalidate() + neighborCallbackListener = nil + neighborCallbackHandler = nil + #endif } public func send(_ notification: LibboxNotification?) throws { @@ -492,6 +497,50 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc #endif } + #if os(macOS) + private var neighborCallbackListener: NSXPCListener? + private var neighborCallbackHandler: NeighborCallbackHandler? + #endif + + public func startNeighborMonitor(_ listener: LibboxNeighborUpdateListenerProtocol?) throws { + #if os(macOS) + guard let listener else { return } + if Variant.useSystemExtension { + let handler = NeighborCallbackHandler(listener) + let xpcListener = NSXPCListener.anonymous() + xpcListener.delegate = handler + xpcListener.resume() + try RootHelperClient.shared.startNeighborMonitor( + callbackEndpoint: xpcListener.endpoint + ) + neighborCallbackListener = xpcListener + neighborCallbackHandler = handler + return + } + #endif + } + + public func registerMyInterface(_ name: String?) { + #if os(macOS) + guard let name, !name.isEmpty else { return } + if Variant.useSystemExtension { + try? RootHelperClient.shared.registerMyInterface(name: name) + } + #endif + } + + public func closeNeighborMonitor(_: LibboxNeighborUpdateListenerProtocol?) throws { + #if os(macOS) + if Variant.useSystemExtension { + try? RootHelperClient.shared.closeNeighborMonitor() + neighborCallbackListener?.invalidate() + neighborCallbackListener = nil + neighborCallbackHandler = nil + return + } + #endif + } + public func localDNSTransport() -> (any LibboxLocalDNSTransportProtocol)? { nil } @@ -500,3 +549,51 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc nil } } + +#if os(macOS) + private class NeighborCallbackHandler: NSObject, NSXPCListenerDelegate, NeighborTableListenerProtocol { + private let listener: LibboxNeighborUpdateListenerProtocol + + init(_ listener: LibboxNeighborUpdateListenerProtocol) { + self.listener = listener + } + + func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { + let exportedInterface = NSXPCInterface(with: NeighborTableListenerProtocol.self) + RootHelperXPC.configureListenerInterface(exportedInterface) + newConnection.exportedInterface = exportedInterface + newConnection.exportedObject = self + newConnection.resume() + return true + } + + func updateNeighborTable(entries: NSArray) { + let iterator = NeighborEntryArrayIterator(entries) + listener.updateNeighborTable(iterator) + } + } + + private class NeighborEntryArrayIterator: NSObject, LibboxNeighborEntryIteratorProtocol { + private var entries: [NeighborEntryResult] + private var index = 0 + + init(_ array: NSArray) { + entries = array.compactMap { $0 as? NeighborEntryResult } + } + + func hasNext() -> Bool { + index < entries.count + } + + func next() -> LibboxNeighborEntry? { + guard index < entries.count else { return nil } + let result = entries[index] + index += 1 + let entry = LibboxNeighborEntry() + entry.address = result.address + entry.macAddress = result.macAddress + entry.hostname = result.hostname + return entry + } + } +#endif diff --git a/Library/Network/RootHelperXPC.swift b/Library/Network/RootHelperXPC.swift index 623b1bd..3e75f44 100644 --- a/Library/Network/RootHelperXPC.swift +++ b/Library/Network/RootHelperXPC.swift @@ -4,6 +4,36 @@ private let logger = Logger(category: "RootHelperXPC") + @objc(NeighborEntryResult) public class NeighborEntryResult: NSObject, NSSecureCoding { + public static let supportsSecureCoding = true + + @objc public var address: String + @objc public var macAddress: String + @objc public var hostname: String + + public init(address: String, macAddress: String, hostname: String) { + self.address = address + self.macAddress = macAddress + self.hostname = hostname + } + + public required init?(coder: NSCoder) { + address = coder.decodeObject(of: NSString.self, forKey: "address") as? String ?? "" + macAddress = coder.decodeObject(of: NSString.self, forKey: "macAddress") as? String ?? "" + hostname = coder.decodeObject(of: NSString.self, forKey: "hostname") as? String ?? "" + } + + public func encode(with coder: NSCoder) { + coder.encode(address as NSString, forKey: "address") + coder.encode(macAddress as NSString, forKey: "macAddress") + coder.encode(hostname as NSString, forKey: "hostname") + } + } + + @objc public protocol NeighborTableListenerProtocol { + func updateNeighborTable(entries: NSArray) + } + @objc public class ConnectionOwnerResult: NSObject, NSSecureCoding { public static let supportsSecureCoding = true @@ -43,6 +73,9 @@ func getWorkingDirectorySize(reply: @escaping (Int64, NSError?) -> Void) func cleanWorkingDirectory(reply: @escaping (NSError?) -> Void) func getVersion(reply: @escaping (String) -> Void) + func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) + func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) } public enum RootHelperXPC { @@ -54,6 +87,23 @@ argumentIndex: 0, ofReply: true ) + let endpointClasses = NSSet(array: [NSXPCListenerEndpoint.self]) as! Set + interface.setClasses( + endpointClasses, + for: #selector(RootHelperProtocol.startNeighborMonitor(callbackEndpoint:reply:)), + argumentIndex: 0, + ofReply: false + ) + } + + public static func configureListenerInterface(_ interface: NSXPCInterface) { + let entryClasses = NSSet(array: [NSArray.self, NeighborEntryResult.self]) as! Set + interface.setClasses( + entryClasses, + for: #selector(NeighborTableListenerProtocol.updateNeighborTable(entries:)), + argumentIndex: 0, + ofReply: false + ) } } @@ -219,6 +269,24 @@ } } + public func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint) throws { + try performXPCCallVoid("startNeighborMonitor") { proxy, reply in + proxy.startNeighborMonitor(callbackEndpoint: callbackEndpoint, reply: reply) + } + } + + public func closeNeighborMonitor() throws { + try performXPCCallVoid("closeNeighborMonitor") { proxy, reply in + proxy.closeNeighborMonitor(reply: reply) + } + } + + public func registerMyInterface(name: String) throws { + try performXPCCallVoid("registerMyInterface") { proxy, reply in + proxy.registerMyInterface(name: name, reply: reply) + } + } + public func getVersion() throws -> String { let semaphore = DispatchSemaphore(value: 0) var result: String?