diff --git a/Anywhere Network Extension/TunnelStack/TunnelStack+Lifecycle.swift b/Anywhere Network Extension/TunnelStack/TunnelStack+Lifecycle.swift index badef7e..4f39c9a 100644 --- a/Anywhere Network Extension/TunnelStack/TunnelStack+Lifecycle.swift +++ b/Anywhere Network Extension/TunnelStack/TunnelStack+Lifecycle.swift @@ -117,7 +117,7 @@ extension TunnelStack { /// /// Deliberately conservative: it does NOT re-resolve DNS or rebuild the mux /// (there's no path to dial over) and does NOT force-close the app-facing TCP - /// legs. A leg riding a freed shared session (Hysteria/AnyTLS/HTTP3, or a + /// legs. A leg riding a freed shared session (Hysteria/Nowhere/AnyTLS/HTTP3, or a /// UDP-over-mux flow) sees a graceful downlink EOF — ``MuxManager/closeAll`` /// and friends deliver no error — and winds down on its own; a leg actively /// writing when its session drops aborts on the failed send; per-connection @@ -132,6 +132,7 @@ extension TunnelStack { logger.info("[VPN] Path offline/sleep: releasing upstream transports; will rebuild when it returns") HysteriaClient.closeAll() + NowhereClient.closeAll() AnyTLSManager.shared.closeAll() HTTP3SessionPool.shared.closeAll() @@ -241,6 +242,7 @@ extension TunnelStack { } HysteriaClient.closeAll() + NowhereClient.closeAll() AnyTLSManager.shared.closeAll() HTTP3SessionPool.shared.closeAll() @@ -291,6 +293,7 @@ extension TunnelStack { } HysteriaClient.closeAll() + NowhereClient.closeAll() HTTP3SessionPool.shared.closeAll() // mux / SS sessions / flows are udpQueue-owned — close them there and diff --git a/Anywhere Network Extension/UDPFlow.swift b/Anywhere Network Extension/UDPFlow.swift index 1784a56..dcbf558 100644 --- a/Anywhere Network Extension/UDPFlow.swift +++ b/Anywhere Network Extension/UDPFlow.swift @@ -131,6 +131,15 @@ class UDPFlow { return false } } + if let nErr = error as? NowhereError { + switch nErr { + case .streamClosed, .authFailed, .invalidTargetLength, + .destinationTooLargeForDatagram: + return true + case .notReady, .connectionFailed: + return false + } + } if let qErr = error as? QUICConnection.QUICError { switch qErr { case .closed, .streamReset, .streamClosedWithError, .handshakeFailed: diff --git a/Anywhere TV/TVProxyEditorViewController.swift b/Anywhere TV/TVProxyEditorViewController.swift index 6e14ef6..4bda42d 100644 --- a/Anywhere TV/TVProxyEditorViewController.swift +++ b/Anywhere TV/TVProxyEditorViewController.swift @@ -61,6 +61,9 @@ class TVProxyEditorViewController: UITableViewController { private var hysteriaCC: HysteriaCongestionControl = .brutal private var hysteriaUploadMbpsText = String(HysteriaCongestionControl.uploadMbpsDefault) private var hysteriaDownloadMbpsText = String(HysteriaCongestionControl.downloadMbpsDefault) + + // Nowhere fields + private var nowhereKey = "" // Trojan fields private var trojanPassword = "" @@ -97,6 +100,7 @@ class TVProxyEditorViewController: UITableViewController { private var isVLESS: Bool { selectedProtocol == .vless } private var isHysteria: Bool { selectedProtocol == .hysteria } + private var isNowhere: Bool { selectedProtocol == .nowhere } private var isTrojan: Bool { selectedProtocol == .trojan } private var isAnyTLS: Bool { selectedProtocol == .anytls } private var isShadowsocks: Bool { selectedProtocol == .shadowsocks } @@ -125,6 +129,7 @@ class TVProxyEditorViewController: UITableViewController { case tlsSNI, tlsALPN, fingerprint case realitySNI, realityPublicKey, realityShortId case hysteriaPassword, hysteriaCC, hysteriaUploadMbps, hysteriaDownloadMbps + case nowhereKey case trojanPassword case anytlsPassword case ssPassword, ssMethod @@ -149,6 +154,7 @@ class TVProxyEditorViewController: UITableViewController { let protocolOptions: [(String, String)] = [ ("VLESS", "vless"), ("Hysteria", "hysteria"), + ("Nowhere", "nowhere"), ("Trojan", "trojan"), ("AnyTLS", "anytls"), ("Shadowsocks", "shadowsocks"), @@ -182,6 +188,8 @@ class TVProxyEditorViewController: UITableViewController { serverRows.append(.text(label: String(localized: "Upload Speed", comment: "Upload Speed for Hysteria protocol"), value: hysteriaUploadMbpsText, placeholder: String(localized: "Mbps"), key: .hysteriaUploadMbps)) serverRows.append(.text(label: String(localized: "Download Speed", comment: "Download Speed for Hysteria protocol"), value: hysteriaDownloadMbpsText, placeholder: String(localized: "Mbps"), key: .hysteriaDownloadMbps)) } + } else if isNowhere { + serverRows.append(.text(label: String(localized: "Key"), value: nowhereKey, placeholder: String(localized: "Key"), key: .nowhereKey, secure: true)) } else if isTrojan { serverRows.append(.text(label: String(localized: "Password"), value: trojanPassword, placeholder: String(localized: "Password"), key: .trojanPassword, secure: true)) } else if isAnyTLS { @@ -376,6 +384,9 @@ class TVProxyEditorViewController: UITableViewController { } return true } + if isNowhere { + return !nowhereKey.isEmpty + } if isTrojan { return !trojanPassword.isEmpty } if isAnyTLS { return !anytlsPassword.isEmpty } if isShadowsocks { return !ssPassword.isEmpty } @@ -538,7 +549,7 @@ class TVProxyEditorViewController: UITableViewController { case .outboundProtocol: if let proto = OutboundProtocol(rawValue: value) { selectedProtocol = proto - if isHysteria || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { + if isHysteria || isNowhere || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { flow = "" if security == "reality" { security = "none" } } @@ -575,6 +586,7 @@ class TVProxyEditorViewController: UITableViewController { if let cc = HysteriaCongestionControl(rawValue: value) { hysteriaCC = cc } case .hysteriaUploadMbps: hysteriaUploadMbpsText = value case .hysteriaDownloadMbps: hysteriaDownloadMbpsText = value + case .nowhereKey: nowhereKey = value case .trojanPassword: trojanPassword = value case .anytlsPassword: anytlsPassword = value case .ssPassword: ssPassword = value @@ -664,6 +676,8 @@ class TVProxyEditorViewController: UITableViewController { hysteriaCC = congestionControl hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) + case .nowhere(let key): + nowhereKey = key case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -756,7 +770,7 @@ class TVProxyEditorViewController: UITableViewController { private func save() { guard let port = UInt16(serverPort) else { return } let parsedUUID: UUID - if isHysteria || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { + if isHysteria || isNowhere || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { parsedUUID = existingConfiguration?.id ?? UUID() } else { guard let uuid = UUID(uuidString: uuid) else { return } @@ -847,6 +861,10 @@ class TVProxyEditorViewController: UITableViewController { downloadMbps: down, sni: existingSNI ?? bareAddress ) + case .nowhere: + outbound = .nowhere( + key: nowhereKey + ) case .trojan: let sniValue = tlsSNI.isEmpty ? bareAddress : tlsSNI let alpn: [String]? = tlsALPN.isEmpty ? nil : tlsALPN.split(separator: ",").map { String($0) } diff --git a/Anywhere.xcodeproj/project.pbxproj b/Anywhere.xcodeproj/project.pbxproj index 3b2928a..f06be0e 100644 --- a/Anywhere.xcodeproj/project.pbxproj +++ b/Anywhere.xcodeproj/project.pbxproj @@ -166,6 +166,7 @@ "Networking/Protocols/Core/ProxyClient+AnyTLS.swift", "Networking/Protocols/Core/ProxyClient+Hysteria.swift", "Networking/Protocols/Core/ProxyClient+Naive.swift", + "Networking/Protocols/Core/ProxyClient+Nowhere.swift", "Networking/Protocols/Core/ProxyClient+Shadowsocks.swift", "Networking/Protocols/Core/ProxyClient+SOCKS5.swift", "Networking/Protocols/Core/ProxyClient+Sudoku.swift", @@ -197,6 +198,12 @@ Networking/Protocols/Hysteria/HysteriaProtocol.swift, Networking/Protocols/Hysteria/HysteriaSession.swift, Networking/Protocols/Hysteria/HysteriaUDPConnection.swift, + Networking/Protocols/Nowhere/NowhereClient.swift, + Networking/Protocols/Nowhere/NowhereConfiguration.swift, + Networking/Protocols/Nowhere/NowhereConnection.swift, + Networking/Protocols/Nowhere/NowhereProtocol.swift, + Networking/Protocols/Nowhere/NowhereSession.swift, + Networking/Protocols/Nowhere/NowhereUDPConnection.swift, Networking/Protocols/Mux/MuxClient.swift, Networking/Protocols/Mux/MuxFrame.swift, Networking/Protocols/Mux/MuxManager.swift, diff --git a/Anywhere/DeepLinkManager.swift b/Anywhere/DeepLinkManager.swift index 65b59e3..c66de84 100644 --- a/Anywhere/DeepLinkManager.swift +++ b/Anywhere/DeepLinkManager.swift @@ -20,7 +20,7 @@ final class DeepLinkManager: ObservableObject { switch url.scheme?.lowercased() { case "anywhere": handleAnywhereScheme(url) - case "vless", "hysteria2", "hy2", "trojan", "anytls", "ss", "quic", "sudoku": + case "vless", "hysteria2", "hy2", "nowhere", "trojan", "anytls", "ss", "quic", "sudoku": self.url = url.absoluteString default: break diff --git a/Anywhere/Views/ProxyList/ProxyEditorView.swift b/Anywhere/Views/ProxyList/ProxyEditorView.swift index d7569cf..db06188 100644 --- a/Anywhere/Views/ProxyList/ProxyEditorView.swift +++ b/Anywhere/Views/ProxyList/ProxyEditorView.swift @@ -62,6 +62,9 @@ struct ProxyEditorView: View { @State private var hysteriaDownloadMbpsText = String(HysteriaCongestionControl.downloadMbpsDefault) @State private var hysteriaSNI = "" + // Nowhere fields + @State private var nowhereKey = "" + // Trojan fields @State private var trojanPassword = "" @@ -97,6 +100,7 @@ struct ProxyEditorView: View { private var isVLESS: Bool { selectedProtocol == .vless } private var isHysteria: Bool { selectedProtocol == .hysteria } + private var isNowhere: Bool { selectedProtocol == .nowhere } private var isTrojan: Bool { selectedProtocol == .trojan } private var isAnyTLS: Bool { selectedProtocol == .anytls } private var isShadowsocks: Bool { selectedProtocol == .shadowsocks } @@ -120,6 +124,9 @@ struct ProxyEditorView: View { } return true } + if isNowhere { + return !nowhereKey.isEmpty + } if isTrojan { return !trojanPassword.isEmpty } @@ -166,6 +173,7 @@ struct ProxyEditorView: View { Picker(selection: $selectedProtocol) { Text(String("VLESS")).tag(OutboundProtocol.vless) Text(String("Hysteria")).tag(OutboundProtocol.hysteria) + Text(String("Nowhere")).tag(OutboundProtocol.nowhere) Text(String("Trojan")).tag(OutboundProtocol.trojan) Text(String("AnyTLS")).tag(OutboundProtocol.anytls) Text(String("Shadowsocks")).tag(OutboundProtocol.shadowsocks) @@ -253,7 +261,16 @@ struct ProxyEditorView: View { TextWithColorfulIcon(title: "Download Speed", comment: "Download Speed for Hysteria protocol", systemName: "arrow.down.circle.fill", foregroundColor: .white, backgroundColor: .blue) } } - } else if isTrojan { + } else if isNowhere { + LabeledContent { + SecureField("Key", text: $nowhereKey) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .multilineTextAlignment(.trailing) + } label: { + TextWithColorfulIcon(title: "Key", comment: nil, systemName: "key.fill", foregroundColor: .white, backgroundColor: .green) + } + } else if isTrojan { LabeledContent { SecureField("Password", text: $trojanPassword) .autocorrectionDisabled() @@ -743,6 +760,8 @@ struct ProxyEditorView: View { hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) hysteriaSNI = sni + case .nowhere(let key): + nowhereKey = key case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -823,7 +842,7 @@ struct ProxyEditorView: View { private func save() { guard let port = UInt16(serverPort) else { return } let parsedUUID: UUID - if isHysteria || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { + if isHysteria || isNowhere || isTrojan || isAnyTLS || isShadowsocks || isSOCKS5 || isSudoku || isNaive { parsedUUID = self.configuration?.id ?? UUID() } else { guard let u = UUID(xrayString: uuid) else { return } @@ -932,6 +951,10 @@ struct ProxyEditorView: View { downloadMbps: down, sni: sni ) + case .nowhere: + outbound = .nowhere( + key: nowhereKey + ) case .trojan: let sni = tlsSNI.isEmpty ? bareAddress : tlsSNI let alpn: [String]? = tlsALPN.isEmpty ? nil : tlsALPN.split(separator: ",").map { String($0) } diff --git a/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift b/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift new file mode 100644 index 0000000..81db47d --- /dev/null +++ b/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift @@ -0,0 +1,143 @@ +// +// ProxyClient+Nowhere.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation + +extension ProxyClient { + /// Connects through a Nowhere server. The iOS TUN stack already splits + /// TCP and UDP flows, so this goes directly to Nowhere stream/DATAGRAM + /// sessions instead of using the SOCKS5 ingress. + func connectWithNowhere( + command: ProxyCommand, + destinationHost: String, + destinationPort: UInt16, + completion: @escaping (Result) -> Void + ) { + guard case .nowhere(let key) = configuration.outbound else { + completion(.failure(ProxyError.protocolError("Nowhere key not set"))) + return + } + + let nwConfig = NowhereConfiguration( + proxyHost: configuration.serverAddress, + proxyPort: configuration.serverPort, + key: key + ) + + let bracketedHost = destinationHost.contains(":") ? "[\(destinationHost)]" : destinationHost + let destination = "\(bracketedHost):\(destinationPort)" + + if let chainTunnel = tunnel { + let transport = ProxyConnectionDatagramTransport(connection: chainTunnel) + self.tunnel = nil + let client = NowhereClient.chained(configuration: nwConfig, transport: transport) + dispatchNowhere(client: client, command: command, destination: destination, completion: completion) + return + } + + if let chain = configuration.chain, !chain.isEmpty { + connectPooledChainedNowhere( + nwConfig: nwConfig, + chain: chain, + command: command, + destination: destination, + completion: completion + ) + return + } + + let client = NowhereClient.shared(for: nwConfig) + dispatchNowhere(client: client, command: command, destination: destination, completion: completion) + } + + private func dispatchNowhere( + client: NowhereClient, + command: ProxyCommand, + destination: String, + completion: @escaping (Result) -> Void + ) { + switch command { + case .tcp, .mux: + client.openTCP(destination: destination, completion: completion) + case .udp: + client.openUDP(destination: destination, completion: completion) + } + } + + private func connectPooledChainedNowhere( + nwConfig: NowhereConfiguration, + chain: [ProxyConfiguration], + command: ProxyCommand, + destination: String, + completion: @escaping (Result) -> Void + ) { + let chainSignature = chain.map { $0.id.uuidString }.joined(separator: ":") + + let cascadeCommands: [ProxyCommand] + switch Self.computeChainHopCommands( + chain: chain, + outerProtocol: .nowhere, + outerCommand: command + ) { + case .success(let cmds): + cascadeCommands = cmds + case .failure(let error): + completion(.failure(error)) + return + } + + let nwServerAddress = configuration.serverAddress + let nwServerPort = configuration.serverPort + let useResolvedAddress = useResolvedAddressForDirectDial + + NowhereClient.acquireChained( + configuration: nwConfig, + chainSignature: chainSignature, + builder: { builderCompletion in + var holders: [ProxyClient] = [] + let holdersLock = UnfairLock() + ProxyClient.buildDetachedChainTunnel( + chain: chain, + hopCommands: cascadeCommands, + finalDestination: (nwServerAddress, nwServerPort), + useResolvedAddressForDirectDial: useResolvedAddress, + track: { client in + holdersLock.withLock { holders.append(client) } + } + ) { result in + switch result { + case .success(let chainTunnel): + let snapshot = holdersLock.withLock { holders } + let transport = ProxyConnectionDatagramTransport(connection: chainTunnel) + builderCompletion(.success((transport, snapshot))) + case .failure(let error): + let snapshot = holdersLock.withLock { holders } + for c in snapshot { c.cancel() } + builderCompletion(.failure(error)) + } + } + }, + completion: { [weak self] clientResult in + switch clientResult { + case .success(let client): + if let self { + self.dispatchNowhere( + client: client, + command: command, + destination: destination, + completion: completion + ) + } else { + completion(.failure(ProxyError.connectionFailed("Client deallocated after pool acquire"))) + } + case .failure(let error): + completion(.failure(error)) + } + } + ) + } +} diff --git a/Shared/Networking/Protocols/Core/ProxyClient.swift b/Shared/Networking/Protocols/Core/ProxyClient.swift index 9ac2404..cf52b86 100644 --- a/Shared/Networking/Protocols/Core/ProxyClient.swift +++ b/Shared/Networking/Protocols/Core/ProxyClient.swift @@ -154,9 +154,9 @@ nonisolated class ProxyClient { return } - // Chained Hysteria pools its chain + QUIC session in `HysteriaClient`; - // route through `connectWithHysteria` instead of building a chain here. - if configuration.outboundProtocol == .hysteria { + // Chained QUIC protocols pool their chain + session in their clients; + // route through protocol-specific dispatch instead of building a chain here. + if configuration.outboundProtocol == .hysteria || configuration.outboundProtocol == .nowhere { connectWithCommand( command: command, destinationHost: destinationHost, @@ -494,6 +494,16 @@ nonisolated class ProxyClient { return } + if configuration.outboundProtocol == .nowhere { + connectWithNowhere( + command: command, + destinationHost: destinationHost, + destinationPort: destinationPort, + completion: completion + ) + return + } + if configuration.outboundProtocol == .trojan { connectWithTrojan( command: command, diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift index 085f748..378ac31 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift @@ -77,6 +77,10 @@ extension ProxyConfiguration { downloadMbps: HysteriaCongestionControl.clampDownloadMbps(rawDown), sni: (explicitSNI?.isEmpty == false) ? explicitSNI! : serverAddress ) + case .nowhere: + outbound = .nowhere( + key: (configurationDict["nowhereKey"] as? String) ?? "" + ) case .trojan: let password = (configurationDict["trojanPassword"] as? String) ?? "" let tls = parseTrojanTLS(from: configurationDict, serverAddress: serverAddress) diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift index a51cdce..21f8349 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift @@ -24,6 +24,8 @@ extension ProxyConfiguration { return toVLESSURL() case .hysteria: return toHysteriaURL() + case .nowhere: + return toNowhereURL() case .trojan: return toTrojanURL() case .anytls: @@ -116,6 +118,15 @@ extension ProxyConfiguration { return "hysteria2://\(encodedPassword)@\(bracketedServerAddress):\(serverPort)/\(query)#\(fragment)" } + private func toNowhereURL() -> String { + guard case .nowhere(let key) = outbound else { + return "" + } + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? "" + let fragment = name.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? name + return "nowhere://\(encodedKey)@\(bracketedServerAddress):\(serverPort)#\(fragment)" + } + private func toTrojanURL() -> String { guard case .trojan(let password, let tls) = outbound else { return "" } let encodedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? "" diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift index a00def2..4530006 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift @@ -12,7 +12,7 @@ import Foundation extension ProxyConfiguration { /// URL scheme prefixes that ``parse(url:)`` can handle. - static let parsableURLPrefixes = ["vless://", "hysteria2://", "hy2://", "trojan://", "anytls://", "ss://", "socks5://", "socks://", "sudoku://", "https://", "quic://"] + static let parsableURLPrefixes = ["vless://", "hysteria2://", "hy2://", "nowhere://", "trojan://", "anytls://", "ss://", "socks5://", "socks://", "sudoku://", "https://", "quic://"] /// Whether the given string starts with a URL scheme that ``parse(url:)`` can handle. static func canParseURL(_ string: String) -> Bool { @@ -29,6 +29,9 @@ extension ProxyConfiguration { if url.hasPrefix("hysteria2://") || url.hasPrefix("hy2://") { return try parseHysteria(url: url) } + if url.hasPrefix("nowhere://") { + return try parseNowhere(url: url) + } if url.hasPrefix("trojan://") { return try parseTrojan(url: url) } @@ -48,7 +51,7 @@ extension ProxyConfiguration { return try parseNaive(url: url, protocolOverride: naiveProtocol) } guard url.hasPrefix("vless://") else { - throw ProxyError.invalidURL("URL must start with vless://, trojan://, anytls://, ss://, socks5://, sudoku://, https://, or quic://") + throw ProxyError.invalidURL("URL must start with vless://, hysteria2://, nowhere://, trojan://, anytls://, ss://, socks5://, sudoku://, https://, or quic://") } var urlWithoutScheme = String(url.dropFirst("vless://".count)) @@ -215,6 +218,50 @@ extension ProxyConfiguration { ) ) } + + /// Parse a Nowhere URL. + /// Format: `nowhere://@host:port#name` + private static func parseNowhere(url: String) throws -> ProxyConfiguration { + let rawPrefix = "nowhere://" + var remaining = String(url.dropFirst(rawPrefix.count)) + + var fragmentName: String? + if let hashIndex = remaining.lastIndex(of: "#") { + fragmentName = String(remaining[remaining.index(after: hashIndex)...]).removingPercentEncoding + remaining = String(remaining[..) -> Void]] = [:] + + static func shared(for configuration: NowhereConfiguration) -> NowhereClient { + let key = Key( + host: configuration.proxyHost, + port: configuration.proxyPort, + key: configuration.key, + chainSignature: "" + ) + registryLock.lock() + defer { registryLock.unlock() } + if let existing = registry[key] { return existing } + let client = NowhereClient( + configuration: configuration, + transport: nil, + chainHolders: [], + poolKey: key + ) + registry[key] = client + return client + } + + static func chained( + configuration: NowhereConfiguration, + transport: QUICDatagramTransport + ) -> NowhereClient { + NowhereClient( + configuration: configuration, + transport: transport, + chainHolders: [], + poolKey: nil + ) + } + + static func acquireChained( + configuration: NowhereConfiguration, + chainSignature: String, + builder: @escaping (@escaping (Result<(QUICDatagramTransport, [ProxyClient]), Error>) -> Void) -> Void, + completion: @escaping (Result) -> Void + ) { + let key = Key( + host: configuration.proxyHost, + port: configuration.proxyPort, + key: configuration.key, + chainSignature: chainSignature + ) + + registryLock.lock() + if let existing = registry[key] { + registryLock.unlock() + completion(.success(existing)) + return + } + if pending[key] != nil { + pending[key]?.append(completion) + registryLock.unlock() + return + } + pending[key] = [completion] + registryLock.unlock() + + builder { builderResult in + Self.registryLock.lock() + let queued = Self.pending.removeValue(forKey: key) ?? [] + let outcome: Result + switch builderResult { + case .success(let (transport, holders)): + let client = NowhereClient( + configuration: configuration, + transport: transport, + chainHolders: holders, + poolKey: key + ) + Self.registry[key] = client + outcome = .success(client) + case .failure(let error): + outcome = .failure(error) + } + Self.registryLock.unlock() + for cb in queued { cb(outcome) } + } + } + + private let configuration: NowhereConfiguration + private let transport: QUICDatagramTransport? + private var chainHolders: [ProxyClient] + private let poolKey: Key? + private let lock = UnfairLock() + private var session: NowhereSession? + private var transportConsumed = false + + private init( + configuration: NowhereConfiguration, + transport: QUICDatagramTransport?, + chainHolders: [ProxyClient], + poolKey: Key? + ) { + self.configuration = configuration + self.transport = transport + self.chainHolders = chainHolders + self.poolKey = poolKey + } + + private func acquireSession(completion: @escaping (Result) -> Void) { + lock.lock() + if let existing = session, !existing.poolIsClosed { + lock.unlock() + existing.ensureReady { error in + if let error { completion(.failure(error)) } + else { completion(.success(existing)) } + } + return + } + + if transport != nil && transportConsumed { + if let key = poolKey { + Self.registryLock.lock() + if Self.registry[key] === self { + Self.registry.removeValue(forKey: key) + } + Self.registryLock.unlock() + } + lock.unlock() + completion(.failure(NowhereError.streamClosed)) + return + } + + let newSession = NowhereSession(configuration: configuration, transport: transport) + session = newSession + if transport != nil { transportConsumed = true } + lock.unlock() + + newSession.onClose = { [weak self, weak newSession] in + guard let self, let newSession else { return } + self.handleSessionClose(newSession) + } + + newSession.ensureReady { [weak newSession] error in + guard let newSession else { + completion(.failure(NowhereError.connectionFailed("Session deallocated"))) + return + } + if let error { completion(.failure(error)) } + else { completion(.success(newSession)) } + } + } + + private func handleSessionClose(_ closedSession: NowhereSession) { + lock.lock() + guard session === closedSession else { + lock.unlock() + return + } + session = nil + let holders = chainHolders + chainHolders = [] + if transport != nil, let key = poolKey { + Self.registryLock.lock() + if Self.registry[key] === self { + Self.registry.removeValue(forKey: key) + } + Self.registryLock.unlock() + } + lock.unlock() + + for client in holders { + client.cancel() + } + } + + func openTCP(destination: String, completion: @escaping (Result) -> Void) { + openTCP(destination: destination, retriesLeft: 1, completion: completion) + } + + private func openTCP(destination: String, retriesLeft: Int, completion: @escaping (Result) -> Void) { + acquireSession { [weak self] result in + switch result { + case .failure(let error): + if retriesLeft > 0, Self.isStaleSessionError(error), let self { + self.openTCP(destination: destination, retriesLeft: retriesLeft - 1, completion: completion) + } else { + completion(.failure(error)) + } + case .success(let session): + let conn = NowhereConnection(session: session, destination: destination) + conn.open { error in + if let error { + conn.cancel() + if retriesLeft > 0, Self.isStaleSessionError(error), let self { + self.openTCP(destination: destination, retriesLeft: retriesLeft - 1, completion: completion) + } else { + completion(.failure(error)) + } + } else { + completion(.success(conn)) + } + } + } + } + } + + func openUDP(destination: String, completion: @escaping (Result) -> Void) { + openUDP(destination: destination, retriesLeft: 1, completion: completion) + } + + private func openUDP(destination: String, retriesLeft: Int, completion: @escaping (Result) -> Void) { + acquireSession { [weak self] result in + switch result { + case .failure(let error): + if retriesLeft > 0, Self.isStaleSessionError(error), let self { + self.openUDP(destination: destination, retriesLeft: retriesLeft - 1, completion: completion) + } else { + completion(.failure(error)) + } + case .success(let session): + let conn = NowhereUDPConnection(session: session, destination: destination) + conn.open { error in + if let error { + conn.cancel() + if retriesLeft > 0, Self.isStaleSessionError(error), let self { + self.openUDP(destination: destination, retriesLeft: retriesLeft - 1, completion: completion) + } else { + completion(.failure(error)) + } + } else { + completion(.success(conn)) + } + } + } + } + } + + private static func isStaleSessionError(_ error: Error) -> Bool { + guard let nErr = error as? NowhereError else { return false } + switch nErr { + case .notReady, .streamClosed: return true + default: return false + } + } + + private func invalidateSession() { + lock.lock() + let current = session + session = nil + let holders = chainHolders + chainHolders = [] + if transport != nil, let key = poolKey { + Self.registryLock.lock() + if Self.registry[key] === self { + Self.registry.removeValue(forKey: key) + } + Self.registryLock.unlock() + } + lock.unlock() + + current?.close() + + for client in holders { + client.cancel() + } + } + + static func closeAll() { + registryLock.lock() + let clients = Array(registry.values) + registryLock.unlock() + for client in clients { + client.invalidateSession() + } + } +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereConfiguration.swift b/Shared/Networking/Protocols/Nowhere/NowhereConfiguration.swift new file mode 100644 index 0000000..b7e5441 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereConfiguration.swift @@ -0,0 +1,15 @@ +// +// NowhereConfiguration.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation + +/// Configuration for a Nowhere QUIC session. +struct NowhereConfiguration: Hashable { + let proxyHost: String + let proxyPort: UInt16 + let key: String +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift b/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift new file mode 100644 index 0000000..49eed0b --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift @@ -0,0 +1,236 @@ +// +// NowhereConnection.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation + +nonisolated final class NowhereConnection: ProxyConnection { + + enum State { case idle, openingStream, handshaking, ready, closed } + + private let session: NowhereSession + private let destination: String + + private var _state: State = .idle + private var state: State { + get { _state } + set { + _state = newValue + readyLock.withLock { _isReady = (newValue == .ready) } + } + } + private let readyLock = UnfairLock() + private var _isReady = false + + private var streamID: Int64 = -1 + private var readClosed = false + private var receiveBuffer = Data() + private var pendingReceive: ((Data?, Error?) -> Void)? + private var pendingQuicBytes = 0 + private var openCompletion: ((Error?) -> Void)? + + init(session: NowhereSession, destination: String) { + self.session = session + self.destination = destination + super.init() + } + + override var isConnected: Bool { + readyLock.withLock { _isReady } + } + + override var outerTLSVersion: TLSVersion? { .tls13 } + + func open(completion: @escaping (Error?) -> Void) { + session.queue.async { [weak self] in + guard let self else { completion(NowhereError.streamClosed); return } + guard self.state == .idle else { completion(NowhereError.notReady); return } + self.openCompletion = completion + self.state = .openingStream + + self.session.openTCPStream(for: self) { [weak self] sid, error in + guard let self else { return } + self.session.queue.async { + if let error { + self.fail(error) + return + } + guard let sid else { + self.fail(NowhereError.connectionFailed("No stream")) + return + } + self.streamID = sid + self.sendTCPRequest() + } + } + } + } + + private func sendTCPRequest() { + state = .handshaking + let frame: Data + do { + frame = try NowhereProtocol.encodeTCPRequest(address: destination) + } catch { + fail(error) + return + } + session.writeStream(streamID, data: frame) { [weak self] error in + guard let self else { return } + self.session.queue.async { + if let error { + self.fail(error) + return + } + guard self.state == .handshaking else { return } + self.state = .ready + if let cb = self.openCompletion { + self.openCompletion = nil + cb(nil) + } + self.deliverBufferedOrEOF(eof: self.readClosed) + } + } + } + + func handleStreamData(_ data: Data, fin: Bool) { + if state == .ready, receiveBuffer.isEmpty, !data.isEmpty, + let cb = pendingReceive { + pendingReceive = nil + let ackCount = pendingQuicBytes + data.count + pendingQuicBytes = 0 + let out = Data(data) + if fin { readClosed = true } + session.extendStreamOffset(streamID, count: ackCount) + cb(out, nil) + return + } + + if !data.isEmpty { + pendingQuicBytes += data.count + receiveBuffer.append(data) + } + if fin { readClosed = true } + + guard state == .ready else { return } + deliverBufferedOrEOF(eof: readClosed) + } + + private func deliverBufferedOrEOF(eof: Bool) { + if let cb = pendingReceive, !receiveBuffer.isEmpty { + pendingReceive = nil + let out = receiveBuffer + receiveBuffer = Data() + let ackCount = takePendingQuicBytes() + session.extendStreamOffset(streamID, count: ackCount) + cb(out, nil) + return + } + + if eof, let cb = pendingReceive { + pendingReceive = nil + cb(nil, nil) + } + } + + private func takePendingQuicBytes() -> Int { + let count = pendingQuicBytes + pendingQuicBytes = 0 + return count + } + + func handleSessionError(_ error: Error) { + session.queue.async { [weak self] in self?.fail(error) } + } + + func handleStreamTermination(error: Error?) { + guard state != .closed else { return } + if let error { + fail(error) + return + } + if state != .ready { + fail(NowhereError.connectionFailed("Stream closed before request completed")) + return + } + readClosed = true + state = .closed + if let cb = pendingReceive { + pendingReceive = nil + cb(nil, nil) + } + } + + private func fail(_ error: Error) { + guard state != .closed else { return } + state = .closed + + if let cb = openCompletion { + openCompletion = nil + cb(error) + } + if let cb = pendingReceive { + pendingReceive = nil + cb(nil, error) + } + } + + override func sendRaw(data: Data, completion: @escaping (Error?) -> Void) { + session.queue.async { [weak self] in + guard let self else { completion(NowhereError.streamClosed); return } + guard self.state == .ready else { + completion(self.state == .closed ? NowhereError.streamClosed : NowhereError.notReady) + return + } + self.session.writeStream(self.streamID, data: data, completion: completion) + } + } + + override func sendRaw(data: Data) { + sendRaw(data: data) { _ in } + } + + override func receiveRaw(completion: @escaping (Data?, Error?) -> Void) { + session.queue.async { [weak self] in + guard let self else { + completion(nil, NowhereError.streamClosed) + return + } + if !self.receiveBuffer.isEmpty && self.state == .ready { + let out = self.receiveBuffer + self.receiveBuffer = Data() + let ackCount = self.takePendingQuicBytes() + self.session.extendStreamOffset(self.streamID, count: ackCount) + completion(out, nil) + return + } + if self.state == .closed { + completion(nil, nil) + return + } + if self.readClosed { + completion(nil, nil) + return + } + self.pendingReceive = completion + } + } + + override func cancel() { + session.queue.async { [weak self] in + guard let self, self.state != .closed else { return } + self.state = .closed + if self.streamID >= 0 { + self.session.shutdownStream(self.streamID) + self.session.releaseTCPStream(self.streamID) + } + if let cb = self.pendingReceive { + self.pendingReceive = nil + cb(nil, NowhereError.streamClosed) + } + } + } +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereProtocol.swift b/Shared/Networking/Protocols/Nowhere/NowhereProtocol.swift new file mode 100644 index 0000000..3519048 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereProtocol.swift @@ -0,0 +1,126 @@ +// +// NowhereProtocol.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation +import CryptoKit +import Security + +enum NowhereProtocol { + static let alpn = "nowhere/1" + static let authFrameLength = 72 + static let maxTargetLength = 512 + + private static let authMagic = Data("NWQAUTH1".utf8) + private static let authInfo = Data("nowhere quic auth v1".utf8) + + enum UDPType: UInt8 { + case request = 1 + case response = 2 + case close = 3 + } + + struct UDPMessage { + let type: UInt8 + let flowID: UInt64 + let target: String + let payload: Data + } + + static func makeAuthFrame(key: String) throws -> Data { + var nonce = Data(count: 32) + let rv = nonce.withUnsafeMutableBytes { raw -> Int32 in + guard let ptr = raw.baseAddress else { return errSecAllocate } + return SecRandomCopyBytes(kSecRandomDefault, 32, ptr) + } + guard rv == errSecSuccess else { + throw NowhereError.connectionFailed("Failed to generate auth nonce") + } + + var message = Data() + message.append(authInfo) + message.append(nonce) + + let derived = Data(SHA256.hash(data: Data(key.utf8))) + let tag = HMAC.authenticationCode( + for: message, + using: SymmetricKey(data: derived) + ) + + var frame = Data(capacity: authFrameLength) + frame.append(authMagic) + frame.append(nonce) + frame.append(contentsOf: tag) + return frame + } + + static func encodeTCPRequest(address: String) throws -> Data { + try encodeTarget(address) + } + + static func encodeUDPDatagram(type: UDPType, flowID: UInt64, target: String, payload: Data) throws -> Data { + let targetBytes = try encodeTarget(target) + var out = Data(capacity: 1 + 8 + targetBytes.count + payload.count) + out.append(type.rawValue) + out.append(uint64Bytes(flowID)) + out.append(targetBytes) + out.append(payload) + return out + } + + static func decodeUDPDatagram(_ data: Data) -> UDPMessage? { + guard data.count >= 11 else { return nil } + let type = byte(data, at: 0) + guard type == UDPType.response.rawValue || type == UDPType.close.rawValue else { return nil } + let flowID = readUInt64(data, at: 1) + guard let parsed = decodeTarget(data, offset: 9) else { return nil } + let payload = data.subdata(in: parsed.nextOffset.. Int { + 1 + 8 + 2 + target.utf8.count + } + + private static func encodeTarget(_ target: String) throws -> Data { + let bytes = Data(target.utf8) + guard !bytes.isEmpty, bytes.count <= maxTargetLength else { + throw NowhereError.invalidTargetLength(bytes.count) + } + var out = Data(capacity: 2 + bytes.count) + out.append(UInt8((bytes.count >> 8) & 0xFF)) + out.append(UInt8(bytes.count & 0xFF)) + out.append(bytes) + return out + } + + private static func decodeTarget(_ data: Data, offset: Int) -> (target: String, nextOffset: Data.Index)? { + guard offset + 2 <= data.count else { return nil } + let len = (Int(byte(data, at: offset)) << 8) | Int(byte(data, at: offset + 1)) + guard len > 0, len <= maxTargetLength, offset + 2 + len <= data.count else { return nil } + let start = data.index(data.startIndex, offsetBy: offset + 2) + let end = data.index(start, offsetBy: len) + guard let target = String(data: data[start.. Data { + var v = value.bigEndian + return withUnsafeBytes(of: &v) { Data($0) } + } + + private static func readUInt64(_ data: Data, at offset: Int) -> UInt64 { + data.withUnsafeBytes { raw in + var value: UInt64 = 0 + memcpy(&value, raw.baseAddress!.advanced(by: offset), 8) + return UInt64(bigEndian: value) + } + } + + private static func byte(_ data: Data, at offset: Int) -> UInt8 { + data[data.index(data.startIndex, offsetBy: offset)] + } +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereSession.swift b/Shared/Networking/Protocols/Nowhere/NowhereSession.swift new file mode 100644 index 0000000..1ad13e3 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereSession.swift @@ -0,0 +1,388 @@ +// +// NowhereSession.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation + +enum NowhereError: Error, LocalizedError { + case notReady + case connectionFailed(String) + case authFailed(String) + case streamClosed + case invalidTargetLength(Int) + case destinationTooLargeForDatagram(maxFrame: Int, headerSize: Int) + + var errorDescription: String? { + switch self { + case .notReady: return "Nowhere session not ready" + case .connectionFailed(let message): return "Nowhere connection failed: \(message)" + case .authFailed(let message): return "Nowhere auth failed: \(message)" + case .streamClosed: return "Nowhere stream closed" + case .invalidTargetLength(let length): return "Nowhere target length is invalid (\(length))" + case .destinationTooLargeForDatagram(let frame, let header): + return "Nowhere destination too large for DATAGRAM (peer max \(frame) <= header \(header))" + } + } +} + +nonisolated final class NowhereSession { + + enum State { case idle, connecting, authenticating, ready, closed } + + private let quic: QUICConnection + private let configuration: NowhereConfiguration + + var queue: DispatchQueue { quic.queue } + var isOnQueue: Bool { quic.isOnQueue } + + private var state: State = .idle + private let closeLatch = CloseOnce() + + private var authStreamID: Int64 = -1 + private var readyCallbacks: [(Error?) -> Void] = [] + + var onClose: (() -> Void)? + + private var tcpStreams: [Int64: NowhereConnection] = [:] + private var udpSessions: [UInt64: NowhereUDPConnection] = [:] + private var nextUDPFlowID: UInt64 = 1 + + private var idleCloseWorkItem: DispatchWorkItem? + private static let idleCloseDelay: DispatchTimeInterval = .seconds(60) + + private let _poolLock = UnfairLock() + private var _poolIsClosed = false + private var _poolTCPCount = 0 + private var _poolUDPCount = 0 + + var poolIsClosed: Bool { + _poolLock.lock() + defer { _poolLock.unlock() } + return _poolIsClosed + } + + var hasActiveConnections: Bool { + _poolLock.lock() + defer { _poolLock.unlock() } + return _poolTCPCount > 0 || _poolUDPCount > 0 + } + + init(configuration: NowhereConfiguration, transport: QUICDatagramTransport? = nil) { + self.configuration = configuration + self.quic = QUICConnection( + host: configuration.proxyHost, + port: configuration.proxyPort, + serverName: configuration.proxyHost, + alpn: [NowhereProtocol.alpn], + datagramsEnabled: true, + tuning: .nowhere, + transport: transport + ) + } + +#if DEBUG + deinit { + assert(closeLatch.isClosed, "NowhereSession leaked: freed without close()/failSession") + } +#endif + + func ensureReady(completion: @escaping (Error?) -> Void) { + queue.async { [weak self] in + guard let self else { completion(NowhereError.streamClosed); return } + switch self.state { + case .ready: + completion(nil) + case .closed: + completion(NowhereError.streamClosed) + case .connecting, .authenticating: + self.readyCallbacks.append(completion) + case .idle: + self.state = .connecting + self.readyCallbacks.append(completion) + self.startConnection() + } + } + } + + private func startConnection() { + QUICCrypto.registerCallbacks() + + quic.connectionClosedHandler = { [weak self] error in + self?.failSession(error) + } + quic.streamDataHandler = { [weak self] sid, data, fin in + self?.handleStreamData(sid: sid, data: data, fin: fin) + } + quic.streamTerminationHandler = { [weak self] sid, error in + self?.handleStreamTermination(sid: sid, error: error) + } + quic.datagramHandler = { [weak self] data in + self?.handleDatagram(data) + } + + quic.connect { [weak self] error in + guard let self else { return } + self.queue.async { + if let error { + self.failSession(error) + return + } + self.state = .authenticating + self.sendAuthFrame() + } + } + } + + private func sendAuthFrame() { + guard let sid = quic.openBidiStream() else { + failSession(NowhereError.connectionFailed("Failed to open auth stream")) + return + } + authStreamID = sid + + let frame: Data + do { + frame = try NowhereProtocol.makeAuthFrame(key: configuration.key) + } catch { + failSession(error) + return + } + + quic.writeStream(sid, data: frame, fin: true) { [weak self] error in + guard let self else { return } + self.queue.async { + if let error { + self.failSession(error) + return + } + guard self.state == .authenticating else { return } + self.state = .ready + let callbacks = self.readyCallbacks + self.readyCallbacks.removeAll() + for cb in callbacks { cb(nil) } + } + } + } + + private func handleStreamData(sid: Int64, data: Data, fin: Bool) { + if sid == authStreamID { + if !data.isEmpty { + quic.extendStreamOffset(sid, count: data.count) + } + if state != .ready { + failSession(NowhereError.authFailed("Auth stream returned unexpected data")) + } + return + } + + if let conn = tcpStreams[sid] { + conn.handleStreamData(data, fin: fin) + return + } + + if (sid & 0x01) == 0x01, !data.isEmpty { + quic.extendStreamOffset(sid, count: data.count) + quic.shutdownStream(sid) + } + } + + private func handleStreamTermination(sid: Int64, error: Error?) { + if sid == authStreamID { + if state == .authenticating { + failSession(error ?? NowhereError.authFailed("Auth stream closed before completion")) + } + return + } + guard let conn = tcpStreams.removeValue(forKey: sid) else { return } + _poolLock.lock() + _poolTCPCount = max(0, _poolTCPCount - 1) + _poolLock.unlock() + updateIdleCloseTimer() + conn.handleStreamTermination(error: error) + } + + private func handleDatagram(_ data: Data) { + guard let msg = NowhereProtocol.decodeUDPDatagram(data), + msg.type == NowhereProtocol.UDPType.response.rawValue else { return } + udpSessions[msg.flowID]?.handleIncomingDatagram(msg.payload) + } + + func openTCPStream(for conn: NowhereConnection, completion: @escaping (Int64?, Error?) -> Void) { + queue.async { [weak self] in + guard let self else { completion(nil, NowhereError.streamClosed); return } + guard self.state == .ready else { + completion(nil, NowhereError.notReady) + return + } + guard let sid = self.quic.openBidiStream() else { + completion(nil, NowhereError.connectionFailed("Failed to open TCP stream")) + return + } + self.tcpStreams[sid] = conn + self._poolLock.lock() + self._poolTCPCount += 1 + self._poolLock.unlock() + self.updateIdleCloseTimer() + completion(sid, nil) + } + } + + func writeStream(_ sid: Int64, data: Data, completion: @escaping (Error?) -> Void) { + quic.writeStream(sid, data: data, completion: completion) + } + + func extendStreamOffset(_ sid: Int64, count: Int) { + quic.extendStreamOffset(sid, count: count) + } + + func shutdownStream(_ sid: Int64) { + quic.shutdownStream(sid) + } + + func releaseTCPStream(_ sid: Int64) { + queue.async { [weak self] in + guard let self else { return } + if self.tcpStreams.removeValue(forKey: sid) != nil { + self._poolLock.lock() + self._poolTCPCount = max(0, self._poolTCPCount - 1) + self._poolLock.unlock() + self.updateIdleCloseTimer() + } + } + } + + func registerUDPSession(_ conn: NowhereUDPConnection, completion: @escaping (Result) -> Void) { + let body = { [weak self] in + guard let self else { + completion(.failure(NowhereError.streamClosed)) + return + } + guard self.state == .ready else { + completion(.failure(NowhereError.notReady)) + return + } + guard self.udpSessions.count < Int.max else { + completion(.failure(NowhereError.connectionFailed("UDP flow pool exhausted"))) + return + } + var fid = self.nextUDPFlowID + while fid == 0 || self.udpSessions[fid] != nil { + fid = fid == UInt64.max ? 1 : fid + 1 + } + self.nextUDPFlowID = fid == UInt64.max ? 1 : fid + 1 + self.udpSessions[fid] = conn + self._poolLock.lock() + self._poolUDPCount += 1 + self._poolLock.unlock() + self.updateIdleCloseTimer() + completion(.success(fid)) + } + if isOnQueue { body() } else { queue.async(execute: body) } + } + + func releaseUDPSession(_ flowID: UInt64) { + queue.async { [weak self] in + guard let self else { return } + if self.udpSessions.removeValue(forKey: flowID) != nil { + self._poolLock.lock() + self._poolUDPCount = max(0, self._poolUDPCount - 1) + self._poolLock.unlock() + self.updateIdleCloseTimer() + } + } + } + + func writeDatagram(_ datagram: Data, completion: @escaping (Error?) -> Void) { + quic.writeDatagram(datagram, completion: completion) + } + + var maxDatagramPayloadSize: Int { + quic.maxDatagramPayloadSize + } + + private func updateIdleCloseTimer() { + idleCloseWorkItem?.cancel() + idleCloseWorkItem = nil + + guard state == .ready else { return } + _poolLock.lock() + let total = _poolTCPCount + _poolUDPCount + _poolLock.unlock() + guard total == 0 else { return } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self._poolLock.lock() + let liveCount = self._poolTCPCount + self._poolUDPCount + self._poolLock.unlock() + guard liveCount == 0, self.state == .ready else { return } + self.close() + } + idleCloseWorkItem = work + queue.asyncAfter(deadline: .now() + Self.idleCloseDelay, execute: work) + } + + func close() { + let work = { + guard self.closeLatch.begin() else { return } + self.state = .closed + self.idleCloseWorkItem?.cancel() + self.idleCloseWorkItem = nil + + self._poolLock.lock() + self._poolIsClosed = true + self._poolTCPCount = 0 + self._poolUDPCount = 0 + self._poolLock.unlock() + + let tcp = Array(self.tcpStreams.values) + self.tcpStreams.removeAll() + for c in tcp { c.handleSessionError(NowhereError.connectionFailed("Session closed")) } + + let udp = Array(self.udpSessions.values) + self.udpSessions.removeAll() + for c in udp { c.handleSessionError(NowhereError.connectionFailed("Session closed")) } + + self.quic.close() + self.onClose?() + } + if isOnQueue { + work() + } else { + queue.async(execute: work) + } + } + + private func failSession(_ error: Error) { + queue.async { + guard self.closeLatch.begin() else { return } + self.state = .closed + self.idleCloseWorkItem?.cancel() + self.idleCloseWorkItem = nil + + self._poolLock.lock() + self._poolIsClosed = true + self._poolTCPCount = 0 + self._poolUDPCount = 0 + self._poolLock.unlock() + + let callbacks = self.readyCallbacks + self.readyCallbacks.removeAll() + for cb in callbacks { cb(error) } + + let tcp = Array(self.tcpStreams.values) + self.tcpStreams.removeAll() + for c in tcp { c.handleSessionError(error) } + + let udp = Array(self.udpSessions.values) + self.udpSessions.removeAll() + for c in udp { c.handleSessionError(error) } + + self.quic.close() + self.onClose?() + } + } +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift b/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift new file mode 100644 index 0000000..413f9b7 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift @@ -0,0 +1,188 @@ +// +// NowhereUDPConnection.swift +// Anywhere +// +// Created by NodePassProject on 5/30/26. +// + +import Foundation + +nonisolated final class NowhereUDPConnection: ProxyConnection { + + enum State { case idle, ready, closed } + + private let session: NowhereSession + private let destination: String + + private var _state: State = .idle + private var state: State { + get { _state } + set { + _state = newValue + readyLock.withLock { _isReady = (newValue == .ready) } + } + } + private let readyLock = UnfairLock() + private var _isReady = false + + private var flowID: UInt64 = 0 + private var packetQueue: [Data] = [] + private static let maxQueuedPackets = 1024 + private var pendingReceive: ((Data?, Error?) -> Void)? + private var closureError: Error? + + init(session: NowhereSession, destination: String) { + self.session = session + self.destination = destination + super.init() + } + + override var isConnected: Bool { + readyLock.withLock { _isReady } + } + + override var outerTLSVersion: TLSVersion? { .tls13 } + override var deliversDatagrams: Bool { true } + + func open(completion: @escaping (Error?) -> Void) { + session.registerUDPSession(self) { [weak self] result in + guard let self else { + completion(NowhereError.streamClosed) + return + } + switch result { + case .failure(let error): + completion(error) + case .success(let fid): + self.flowID = fid + self.state = .ready + completion(nil) + } + } + } + + func handleIncomingDatagram(_ payload: Data) { + guard state != .closed, !payload.isEmpty else { return } + if let cb = pendingReceive { + pendingReceive = nil + cb(payload, nil) + return + } + if packetQueue.count >= Self.maxQueuedPackets { + packetQueue.removeFirst() + } + packetQueue.append(payload) + } + + override func sendRaw(data: Data, completion: @escaping (Error?) -> Void) { + guard !data.isEmpty else { + completion(nil) + return + } + session.queue.async { [weak self] in + guard let self else { completion(NowhereError.streamClosed); return } + guard self.state == .ready else { + completion(self.state == .closed ? NowhereError.streamClosed : NowhereError.notReady) + return + } + self.sendDatagramPayload(data, completion: completion) + } + } + + override func sendRaw(data: Data) { + sendRaw(data: data) { _ in } + } + + private func sendDatagramPayload(_ payload: Data, completion: @escaping (Error?) -> Void) { + let maxSize = session.maxDatagramPayloadSize + let headerSize = NowhereProtocol.udpHeaderSize(target: destination) + guard maxSize > headerSize else { + completion(NowhereError.destinationTooLargeForDatagram(maxFrame: maxSize, headerSize: headerSize)) + return + } + guard payload.count <= maxSize - headerSize else { + completion(QUICConnection.QUICError.datagramTooLarge(maxBound: maxSize - headerSize)) + return + } + + let frame: Data + do { + frame = try NowhereProtocol.encodeUDPDatagram( + type: .request, + flowID: flowID, + target: destination, + payload: payload + ) + } catch { + completion(error) + return + } + session.writeDatagram(frame, completion: completion) + } + + override func receiveRaw(completion: @escaping (Data?, Error?) -> Void) { + session.queue.async { [weak self] in + guard let self else { + completion(nil, NowhereError.streamClosed) + return + } + if !self.packetQueue.isEmpty { + let packet = self.packetQueue.removeFirst() + completion(packet, nil) + return + } + if let err = self.closureError { + self.closureError = nil + completion(nil, err) + return + } + if self.state == .closed { + completion(nil, nil) + return + } + assert(self.pendingReceive == nil, "NowhereUDPConnection: overlapping receiveRaw call") + let stale = self.pendingReceive + self.pendingReceive = completion + stale?(nil, NowhereError.connectionFailed("overlapping receiveRaw on Nowhere UDP")) + } + } + + override func cancel() { + session.queue.async { [weak self] in + guard let self, self.state != .closed else { return } + self.state = .closed + self.sendCloseFrame() + self.session.releaseUDPSession(self.flowID) + let cb = self.pendingReceive + self.pendingReceive = nil + self.packetQueue.removeAll() + cb?(nil, NowhereError.streamClosed) + } + } + + private func sendCloseFrame() { + guard flowID != 0 else { return } + let frame = try? NowhereProtocol.encodeUDPDatagram( + type: .close, + flowID: flowID, + target: destination, + payload: Data() + ) + if let frame { + session.writeDatagram(frame) { _ in } + } + } + + func handleSessionError(_ error: Error) { + session.queue.async { [weak self] in + guard let self, self.state != .closed else { return } + self.state = .closed + let cb = self.pendingReceive + self.pendingReceive = nil + if cb == nil { + self.closureError = error + } + cb?(nil, error) + } + } +} diff --git a/Shared/Networking/Protocols/QUIC/QUICDatagramTransport.swift b/Shared/Networking/Protocols/QUIC/QUICDatagramTransport.swift index 6ee1911..17820fa 100644 --- a/Shared/Networking/Protocols/QUIC/QUICDatagramTransport.swift +++ b/Shared/Networking/Protocols/QUIC/QUICDatagramTransport.swift @@ -107,6 +107,15 @@ final class ProxyConnectionDatagramTransport: QUICDatagramTransport { return true } } + if let nErr = error as? NowhereError { + switch nErr { + case .streamClosed, .authFailed, .invalidTargetLength, + .destinationTooLargeForDatagram: + return false + case .notReady, .connectionFailed: + return true + } + } return false } diff --git a/Shared/Networking/Protocols/QUIC/QUICTuning.swift b/Shared/Networking/Protocols/QUIC/QUICTuning.swift index 26025fa..4c3e833 100644 --- a/Shared/Networking/Protocols/QUIC/QUICTuning.swift +++ b/Shared/Networking/Protocols/QUIC/QUICTuning.swift @@ -160,4 +160,20 @@ extension QUICTuning { ) } } + + static let nowhere = QUICTuning( + cc: .cubic, + maxStreamWindow: 16 * 1024 * 1024, + maxWindow: 32 * 1024 * 1024, + initialMaxData: 8 * 1024 * 1024, + initialMaxStreamDataBidiLocal: 2 * 1024 * 1024, + initialMaxStreamDataBidiRemote: 2 * 1024 * 1024, + initialMaxStreamDataUni: 2 * 1024 * 1024, + initialMaxStreamsBidi: 1024, + initialMaxStreamsUni: 16, + maxIdleTimeout: 30 * 1_000_000_000, + handshakeTimeout: 10 * 1_000_000_000, + keepAliveTimeout: 10 * 1_000_000_000, + disableActiveMigration: true + ) } diff --git a/Shared/ViewModels/VPNViewModel.swift b/Shared/ViewModels/VPNViewModel.swift index 4917bd3..d5b7230 100644 --- a/Shared/ViewModels/VPNViewModel.swift +++ b/Shared/ViewModels/VPNViewModel.swift @@ -817,6 +817,8 @@ class VPNViewModel: ObservableObject { configurationDict["hysteriaUploadMbps"] = uploadMbps configurationDict["hysteriaDownloadMbps"] = downloadMbps configurationDict["hysteriaSNI"] = sni + case .nowhere(let key): + configurationDict["nowhereKey"] = key case .trojan(let password, let tls): configurationDict["trojanPassword"] = password configurationDict["trojanSNI"] = tls.serverName