Files
navi/ios/NavisTunnel/PacketTunnelProvider.swift
T
M4andCursor 7ceb3a5148
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 22:46:32 +03:00

270 lines
11 KiB
Swift

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<sockaddr_in>.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<sockaddr_in>.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<addrinfo>?
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
}
}
}