|
1 | 1 | import Foundation |
| 2 | +import Libbox |
2 | 3 | import Library |
| 4 | +import Network |
3 | 5 | import os |
4 | 6 |
|
5 | 7 | private let logger = Logger(category: "RootHelper") |
6 | 8 |
|
| 9 | +private class NeighborGoListener: NSObject, LibboxNeighborUpdateListenerProtocol { |
| 10 | + private weak var service: RootHelperService? |
| 11 | + |
| 12 | + init(service: RootHelperService) { |
| 13 | + self.service = service |
| 14 | + } |
| 15 | + |
| 16 | + func updateNeighborTable(_ entries: (any LibboxNeighborEntryIteratorProtocol)?) { |
| 17 | + guard let entries, let service else { return } |
| 18 | + service.pushNeighborTable(entries: entries) |
| 19 | + } |
| 20 | +} |
| 21 | + |
7 | 22 | class RootHelperService: NSObject { |
8 | 23 | private var listener: NSXPCListener? |
| 24 | + private var neighborSubscription: LibboxNeighborSubscription? |
| 25 | + private var neighborCallbackConnection: NSXPCConnection? |
| 26 | + private var neighborLeaseWatcher: DispatchSourceFileSystemObject? |
| 27 | + private var pathMonitor: NWPathMonitor? |
| 28 | + private var pendingNATFlush: DispatchWorkItem? |
| 29 | + private var tunInterfaceName: String? |
9 | 30 |
|
10 | 31 | func start() { |
11 | 32 | listener = NSXPCListener(machServiceName: AppConfiguration.rootHelperMachService) |
@@ -86,4 +107,249 @@ extension RootHelperService: RootHelperProtocol { |
86 | 107 | func getVersion(reply: @escaping (String) -> Void) { |
87 | 108 | reply(Bundle.main.version) |
88 | 109 | } |
| 110 | + |
| 111 | + func startNeighborMonitor(callbackEndpoint: NSXPCListenerEndpoint, reply: @escaping (NSError?) -> Void) { |
| 112 | + logger.info("startNeighborMonitor") |
| 113 | + closeNeighborMonitorInternal() |
| 114 | + |
| 115 | + let callbackConnection = NSXPCConnection(listenerEndpoint: callbackEndpoint) |
| 116 | + let listenerInterface = NSXPCInterface(with: NeighborTableListenerProtocol.self) |
| 117 | + RootHelperXPC.configureListenerInterface(listenerInterface) |
| 118 | + callbackConnection.remoteObjectInterface = listenerInterface |
| 119 | + callbackConnection.resume() |
| 120 | + neighborCallbackConnection = callbackConnection |
| 121 | + |
| 122 | + let goListener = NeighborGoListener(service: self) |
| 123 | + var error: NSError? |
| 124 | + let subscription = LibboxSubscribeNeighborTable(goListener, &error) |
| 125 | + if let error { |
| 126 | + logger.error("startNeighborMonitor: \(error.localizedDescription)") |
| 127 | + callbackConnection.invalidate() |
| 128 | + neighborCallbackConnection = nil |
| 129 | + reply(error) |
| 130 | + return |
| 131 | + } |
| 132 | + neighborSubscription = subscription |
| 133 | + startLeaseFileWatcher() |
| 134 | + startNATCleaner() |
| 135 | + reply(nil) |
| 136 | + } |
| 137 | + |
| 138 | + func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) { |
| 139 | + logger.info("registerMyInterface: \(name)") |
| 140 | + tunInterfaceName = name |
| 141 | + flushInternetSharingNAT() |
| 142 | + reply(nil) |
| 143 | + } |
| 144 | + |
| 145 | + func closeNeighborMonitor(reply: @escaping (NSError?) -> Void) { |
| 146 | + logger.info("closeNeighborMonitor") |
| 147 | + closeNeighborMonitorInternal() |
| 148 | + reply(nil) |
| 149 | + } |
| 150 | + |
| 151 | + private func closeNeighborMonitorInternal() { |
| 152 | + neighborSubscription?.close() |
| 153 | + neighborSubscription = nil |
| 154 | + neighborLeaseWatcher?.cancel() |
| 155 | + neighborLeaseWatcher = nil |
| 156 | + pendingNATFlush?.cancel() |
| 157 | + pendingNATFlush = nil |
| 158 | + pathMonitor?.cancel() |
| 159 | + pathMonitor = nil |
| 160 | + tunInterfaceName = nil |
| 161 | + neighborCallbackConnection?.invalidate() |
| 162 | + neighborCallbackConnection = nil |
| 163 | + } |
| 164 | + |
| 165 | + func pushNeighborTable(entries: LibboxNeighborEntryIteratorProtocol) { |
| 166 | + guard let callbackConnection = neighborCallbackConnection else { |
| 167 | + logger.warning("pushNeighborTable: no callback connection") |
| 168 | + return |
| 169 | + } |
| 170 | + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in |
| 171 | + logger.error("pushNeighborTable XPC error: \(error.localizedDescription)") |
| 172 | + }) as? NeighborTableListenerProtocol else { |
| 173 | + logger.warning("pushNeighborTable: failed to get proxy") |
| 174 | + return |
| 175 | + } |
| 176 | + |
| 177 | + let leaseIterator = LibboxReadBootpdLeases() |
| 178 | + var leaseEntries: [NeighborEntryResult] = [] |
| 179 | + var leaseHostnamesByMAC: [String: String] = [:] |
| 180 | + var leaseHostnamesByIP: [String: String] = [:] |
| 181 | + if let leaseIterator { |
| 182 | + while leaseIterator.hasNext() { |
| 183 | + guard let entry = leaseIterator.next() else { continue } |
| 184 | + leaseEntries.append(NeighborEntryResult( |
| 185 | + address: entry.address, |
| 186 | + macAddress: entry.macAddress, |
| 187 | + hostname: entry.hostname |
| 188 | + )) |
| 189 | + if !entry.hostname.isEmpty { |
| 190 | + leaseHostnamesByMAC[entry.macAddress] = entry.hostname |
| 191 | + leaseHostnamesByIP[entry.address] = entry.hostname |
| 192 | + } |
| 193 | + } |
| 194 | + } |
| 195 | + logger.debug("pushNeighborTable: leases=\(leaseEntries.count), hostnames=\(leaseHostnamesByMAC.count)") |
| 196 | + |
| 197 | + var results: [NeighborEntryResult] = [] |
| 198 | + var seenAddresses: Set<String> = [] |
| 199 | + while entries.hasNext() { |
| 200 | + guard let entry = entries.next() else { continue } |
| 201 | + seenAddresses.insert(entry.address) |
| 202 | + var hostname = entry.hostname |
| 203 | + if hostname.isEmpty { |
| 204 | + hostname = leaseHostnamesByIP[entry.address] ?? leaseHostnamesByMAC[entry.macAddress] ?? "" |
| 205 | + } |
| 206 | + results.append(NeighborEntryResult( |
| 207 | + address: entry.address, |
| 208 | + macAddress: entry.macAddress, |
| 209 | + hostname: hostname |
| 210 | + )) |
| 211 | + } |
| 212 | + for leaseEntry in leaseEntries { |
| 213 | + if !seenAddresses.contains(leaseEntry.address) { |
| 214 | + results.append(leaseEntry) |
| 215 | + } |
| 216 | + } |
| 217 | + logger.debug("pushNeighborTable: \(results.count) entries") |
| 218 | + proxy.updateNeighborTable(entries: results as NSArray) |
| 219 | + } |
| 220 | + |
| 221 | + private func startNATCleaner() { |
| 222 | + flushInternetSharingNAT() |
| 223 | + let monitor = NWPathMonitor() |
| 224 | + let queue = DispatchQueue(label: "nat-cleaner") |
| 225 | + monitor.pathUpdateHandler = { [weak self] path in |
| 226 | + guard let self else { return } |
| 227 | + logger.debug("NATCleaner: path update, status=\(String(describing: path.status)), interfaces=\(path.availableInterfaces.map(\.name))") |
| 228 | + self.pendingNATFlush?.cancel() |
| 229 | + let workItem = DispatchWorkItem { [weak self] in |
| 230 | + self?.flushInternetSharingNAT() |
| 231 | + } |
| 232 | + self.pendingNATFlush = workItem |
| 233 | + queue.asyncAfter(deadline: .now() + 2, execute: workItem) |
| 234 | + } |
| 235 | + monitor.start(queue: queue) |
| 236 | + pathMonitor = monitor |
| 237 | + } |
| 238 | + |
| 239 | + private func flushInternetSharingNAT() { |
| 240 | + guard let tunName = tunInterfaceName, !tunName.isEmpty else { |
| 241 | + logger.debug("flushInternetSharingNAT: no tun interface name set") |
| 242 | + return |
| 243 | + } |
| 244 | + let anchors = [ |
| 245 | + "com.apple.internet-sharing/shared_v4", |
| 246 | + "com.apple.internet-sharing/shared_v6", |
| 247 | + ] |
| 248 | + let filter = " on \(tunName) " |
| 249 | + for anchor in anchors { |
| 250 | + removeNATRulesForInterface(anchor: anchor, filter: filter) |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + private func removeNATRulesForInterface(anchor: String, filter: String) { |
| 255 | + let readProcess = Process() |
| 256 | + readProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") |
| 257 | + readProcess.arguments = ["-a", anchor, "-s", "nat"] |
| 258 | + let readPipe = Pipe() |
| 259 | + readProcess.standardOutput = readPipe |
| 260 | + readProcess.standardError = FileHandle.nullDevice |
| 261 | + do { |
| 262 | + try readProcess.run() |
| 263 | + } catch { |
| 264 | + logger.error("removeNATRules: failed to read \(anchor): \(error.localizedDescription)") |
| 265 | + return |
| 266 | + } |
| 267 | + let output = String(data: readPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" |
| 268 | + readProcess.waitUntilExit() |
| 269 | + if readProcess.terminationStatus != 0 { |
| 270 | + logger.warning("removeNATRules: pfctl -s nat exited with \(readProcess.terminationStatus) for \(anchor)") |
| 271 | + return |
| 272 | + } |
| 273 | + if output.isEmpty { |
| 274 | + logger.debug("removeNATRules: \(anchor) has no NAT rules") |
| 275 | + return |
| 276 | + } |
| 277 | + guard output.contains(filter) else { |
| 278 | + logger.debug("removeNATRules: \(anchor) has no rules matching \(filter)") |
| 279 | + return |
| 280 | + } |
| 281 | + let lines = output.components(separatedBy: "\n") |
| 282 | + let removed = lines.filter { $0.contains(filter) } |
| 283 | + let remaining = lines.filter { !$0.contains(filter) }.joined(separator: "\n") |
| 284 | + logger.info("removeNATRules: \(anchor): removing \(removed.count) rules matching \(filter), keeping \(lines.count - removed.count) rules") |
| 285 | + for rule in removed { |
| 286 | + logger.debug("removeNATRules: removing: \(rule)") |
| 287 | + } |
| 288 | + let writeProcess = Process() |
| 289 | + writeProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") |
| 290 | + writeProcess.arguments = ["-a", anchor, "-N", "-f", "-"] |
| 291 | + let writePipe = Pipe() |
| 292 | + writePipe.fileHandleForWriting.write(remaining.data(using: .utf8) ?? Data()) |
| 293 | + writePipe.fileHandleForWriting.closeFile() |
| 294 | + writeProcess.standardInput = writePipe |
| 295 | + writeProcess.standardOutput = FileHandle.nullDevice |
| 296 | + let writeErrorPipe = Pipe() |
| 297 | + writeProcess.standardError = writeErrorPipe |
| 298 | + do { |
| 299 | + try writeProcess.run() |
| 300 | + } catch { |
| 301 | + logger.error("removeNATRules: failed to write \(anchor): \(error.localizedDescription)") |
| 302 | + return |
| 303 | + } |
| 304 | + let stderrData = writeErrorPipe.fileHandleForReading.readDataToEndOfFile() |
| 305 | + writeProcess.waitUntilExit() |
| 306 | + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" |
| 307 | + if writeProcess.terminationStatus != 0 { |
| 308 | + logger.error("removeNATRules: pfctl -f exited with \(writeProcess.terminationStatus) for \(anchor), stderr: \(stderrOutput)") |
| 309 | + } else { |
| 310 | + logger.debug("removeNATRules: successfully updated \(anchor)") |
| 311 | + } |
| 312 | + } |
| 313 | + |
| 314 | + private func startLeaseFileWatcher() { |
| 315 | + let leasePath = "/var/db/dhcpd_leases" |
| 316 | + let fd = open(leasePath, O_EVTONLY) |
| 317 | + guard fd >= 0 else { |
| 318 | + logger.warning("startLeaseFileWatcher: failed to open \(leasePath), errno=\(errno)") |
| 319 | + return |
| 320 | + } |
| 321 | + let source = DispatchSource.makeFileSystemObjectSource( |
| 322 | + fileDescriptor: fd, |
| 323 | + eventMask: [.write, .rename], |
| 324 | + queue: DispatchQueue.global() |
| 325 | + ) |
| 326 | + source.setEventHandler { [weak self] in |
| 327 | + guard let self, neighborSubscription != nil else { return } |
| 328 | + guard let callbackConnection = neighborCallbackConnection else { return } |
| 329 | + guard let proxy = callbackConnection.remoteObjectProxyWithErrorHandler({ error in |
| 330 | + logger.error("leaseWatcher push error: \(error.localizedDescription)") |
| 331 | + }) as? NeighborTableListenerProtocol else { |
| 332 | + return |
| 333 | + } |
| 334 | + |
| 335 | + let leaseIterator = LibboxReadBootpdLeases() |
| 336 | + var results: [NeighborEntryResult] = [] |
| 337 | + if let leaseIterator { |
| 338 | + while leaseIterator.hasNext() { |
| 339 | + guard let entry = leaseIterator.next() else { continue } |
| 340 | + results.append(NeighborEntryResult( |
| 341 | + address: entry.address, |
| 342 | + macAddress: entry.macAddress, |
| 343 | + hostname: entry.hostname |
| 344 | + )) |
| 345 | + } |
| 346 | + } |
| 347 | + proxy.updateNeighborTable(entries: results as NSArray) |
| 348 | + } |
| 349 | + source.setCancelHandler { |
| 350 | + close(fd) |
| 351 | + } |
| 352 | + source.resume() |
| 353 | + neighborLeaseWatcher = source |
| 354 | + } |
89 | 355 | } |
0 commit comments