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>
270 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|