import Darwin import Foundation import NetworkExtension import os.log class PacketTunnelProvider: NEPacketTunnelProvider { private let log = Logger(subsystem: "win.evilfox.navis.tunnel", category: "tunnel") private let store = ProfileStore() private var hevQueue: DispatchQueue? private var coreKind: String? override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { log.info("startTunnel") store.setTunnelStatus("starting") let resolved = resolveEndpoint(options: options) let name = resolved.name let uri = resolved.uri if uri.isEmpty { fail(TunnelError.message("нет URI — выберите сервер и Подключить снова"), completionHandler) return } // Persist what we actually use (helps UI + next start). store.writeTunnelConfig(name: name, uri: uri) let proto = Proto.detect(uri) switch proto { case .vless, .vmess, .trojan: break case .hy2: fail(TunnelError.message("Hysteria2 на iOS пока недоступен — выберите VLESS/VMess/Trojan"), completionHandler) return case .naive: fail(TunnelError.message("NaiveProxy на iOS недоступен (Chromium-ядро)"), completionHandler) return case .awg: fail(TunnelError.message("AmneziaWG на iOS — следующим этапом"), completionHandler) return case .unknown: fail(TunnelError.message("Неизвестный протокол"), completionHandler) return } // Critical order: start local SOCKS core BEFORE installing full tunnel routes. // Otherwise traffic to the proxy server is sucked into utun and never reaches the internet. DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { completionHandler(TunnelError.message("tunnel deallocated")) return } do { try self.startCore(uri: uri, proto: proto) try self.waitLocalPort(1080, timeoutMs: 15_000) let serverHost = LinkParser.hostOf(uri) let serverIP = Self.resolveIPv4(serverHost) let settings = Self.makeNetworkSettings(excludeHostOrIP: serverIP ?? serverHost) let lock = DispatchSemaphore(value: 0) var settingsError: Error? self.setTunnelNetworkSettings(settings) { err in settingsError = err lock.signal() } lock.wait() if let settingsError { throw settingsError } try self.startHev() self.store.setTunnelStatus("connected") self.log.info("tunnel up name=\(name, privacy: .public) proto=\(proto.label, privacy: .public) uriHost=\(serverHost, privacy: .public)") completionHandler(nil) } catch { self.log.error("start failed: \(error.localizedDescription, privacy: .public)") self.teardownCore() self.store.setTunnelStatus("error", error: error.localizedDescription) completionHandler(error) } } } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { log.info("stopTunnel reason=\(String(describing: reason), privacy: .public)") teardownCore() store.setTunnelStatus("stopped") store.clearTunnelConfig() completionHandler() } /// Prefer: start options → providerConfiguration → App Group file/defaults. private func resolveEndpoint(options: [String: NSObject]?) -> (name: String, uri: String) { if let uri = options?["uri"] as? String, !uri.isEmpty { let name = (options?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis" return (name, uri) } if let proto = protocolConfiguration as? NETunnelProviderProtocol, let cfg = proto.providerConfiguration { let uri = (cfg["uri"] as? String) ?? "" if !uri.isEmpty { let name = (cfg["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis" return (name, uri) } } if let file = store.readTunnelConfigFile() { return (file.name, file.uri) } if let cfg = store.readTunnelConfig() { return (cfg.name, cfg.uri) } return ("Navis", "") } private func startCore(uri: String, proto: Proto) throws { let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) ?? FileManager.default.temporaryDirectory let runtime = work.appendingPathComponent("runtime", isDirectory: true) try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true) switch proto { case .vless, .vmess, .trojan: let json = try XrayConfigBuilder.buildJSON(uri: uri, socksPort: 1080, httpPort: 1081) let cfgURL = runtime.appendingPathComponent("xray.json") try json.write(to: cfgURL, atomically: true, encoding: .utf8) // Stop any previous instance before starting. LibXrayCore.stop() try LibXrayCore.runFile(cfgURL.path) coreKind = "xray" case .hy2: let yaml = try Hy2ConfigBuilder.buildYAML(uri: uri) let cfgURL = runtime.appendingPathComponent("hy2.yaml") try yaml.write(to: cfgURL, atomically: true, encoding: .utf8) try Hy2CoreRunner.start(configPath: cfgURL.path, workDir: runtime.path) coreKind = "hy2" default: throw TunnelError.message("протокол не поддержан в tunnel") } } private func startHev() throws { let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID) ?? FileManager.default.temporaryDirectory let runtime = work.appendingPathComponent("runtime", isDirectory: true) try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true) guard let tunFd = resolveTunnelFileDescriptor(), tunFd >= 0 else { throw TunnelError.message("не удалось получить utun fd для hev") } let hevCfg = runtime.appendingPathComponent("hev.yml") try HevBridge.writeConfig(to: hevCfg) let queue = DispatchQueue(label: "win.evilfox.navis.hev", qos: .userInitiated) hevQueue = queue queue.async { _ = HevBridge.runBlocking(configPath: hevCfg.path, tunFd: tunFd) } Thread.sleep(forTimeInterval: 0.35) } private func teardownCore() { HevBridge.quit() switch coreKind { case "xray": LibXrayCore.stop() case "hy2": Hy2CoreRunner.stop() default: LibXrayCore.stop() Hy2CoreRunner.stop() } coreKind = nil } private func fail(_ error: Error, _ completionHandler: @escaping (Error?) -> Void) { store.setTunnelStatus("error", error: error.localizedDescription) completionHandler(error) } private func waitLocalPort(_ port: Int, timeoutMs: Int) throws { let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) while Date() < deadline { if tcpConnectOK(port: port) { return } Thread.sleep(forTimeInterval: 0.15) } throw TunnelError.message("локальный SOCKS :\(port) не поднялся (проверьте ссылку VLESS/Trojan)") } private func tcpConnectOK(port: Int) -> Bool { let fd = socket(AF_INET, SOCK_STREAM, 0) guard fd >= 0 else { return false } defer { close(fd) } var addr = sockaddr_in() addr.sin_len = UInt8(MemoryLayout.size) addr.sin_family = sa_family_t(AF_INET) addr.sin_port = in_port_t(UInt16(port).bigEndian) addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) let result = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } } return result == 0 } private static func makeNetworkSettings(excludeHostOrIP: String) -> NEPacketTunnelNetworkSettings { let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.255"]) ipv4.includedRoutes = [NEIPv4Route.default()] var excluded: [NEIPv4Route] = [ NEIPv4Route(destinationAddress: "127.0.0.0", subnetMask: "255.0.0.0"), // Private LAN — do not claim (and avoid eating the tunnel address space unnecessarily). NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"), NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"), NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"), ] // Keep path to proxy server outside the tunnel (prevents routing loop). if !excludeHostOrIP.isEmpty, looksLikeIPv4(excludeHostOrIP) { excluded.append(NEIPv4Route(destinationAddress: excludeHostOrIP, subnetMask: "255.255.255.255")) } ipv4.excludedRoutes = excluded settings.ipv4Settings = ipv4 settings.dnsSettings = NEDNSSettings(servers: ["1.1.1.1", "8.8.8.8"]) settings.mtu = 1400 return settings } private static func looksLikeIPv4(_ s: String) -> Bool { var addr = in_addr() return s.withCString { inet_pton(AF_INET, $0, &addr) == 1 } } private static func resolveIPv4(_ host: String) -> String? { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if looksLikeIPv4(trimmed) { return trimmed } var hints = addrinfo( ai_flags: AI_ADDRCONFIG, ai_family: AF_INET, ai_socktype: SOCK_STREAM, ai_protocol: 0, ai_addrlen: 0, ai_canonname: nil, ai_addr: nil, ai_next: nil ) var res: UnsafeMutablePointer? let rc = getaddrinfo(trimmed, nil, &hints, &res) guard rc == 0, let first = res else { return nil } defer { freeaddrinfo(res) } var addr = first.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee } var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) guard inet_ntop(AF_INET, &addr.sin_addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } return String(cString: buf) } } enum TunnelError: LocalizedError { case message(String) var errorDescription: String? { switch self { case .message(let s): return s } } }