From 40699afff25bc3e1058bc222ed70cbbc0126e611 Mon Sep 17 00:00:00 2001 From: yosebyte Date: Fri, 29 May 2026 15:37:59 +0000 Subject: [PATCH 1/2] Add Nowhere Protocol implementation - Introduced `NowhereClient` for managing QUIC connections and sessions. - Created `NowhereConfiguration` to hold configuration details for QUIC sessions. - Implemented `NowhereConnection` for handling TCP connections over QUIC. - Added `NowhereProtocol` for encoding and decoding QUIC messages and frames. - Developed `NowhereSession` to manage the lifecycle of QUIC sessions and handle authentication. - Implemented `NowhereUDPConnection` for managing UDP connections and datagram handling. - Added error handling through `NowhereError` enum for better error reporting. --- .../TunnelStack/TunnelStack+Lifecycle.swift | 5 +- Anywhere Network Extension/UDPFlow.swift | 9 + Anywhere TV/TVProxyEditorViewController.swift | 31 +- Anywhere.xcodeproj/project.pbxproj | 7 + Anywhere/DeepLinkManager.swift | 2 +- .../Views/ProxyList/ProxyEditorView.swift | 41 +- .../Protocols/Core/ProxyClient+Nowhere.swift | 144 ++++++ .../Protocols/Core/ProxyClient.swift | 16 +- .../Core/ProxyConfiguration+DictParsing.swift | 6 + .../Core/ProxyConfiguration+URLExport.swift | 12 + .../Core/ProxyConfiguration+URLParsing.swift | 56 ++- .../Protocols/Core/ProxyConfiguration.swift | 28 +- .../Protocols/Nowhere/NowhereClient.swift | 294 +++++++++++++ .../Nowhere/NowhereConfiguration.swift | 20 + .../Protocols/Nowhere/NowhereConnection.swift | 236 ++++++++++ .../Protocols/Nowhere/NowhereProtocol.swift | 190 ++++++++ .../Protocols/Nowhere/NowhereSession.swift | 414 ++++++++++++++++++ .../Nowhere/NowhereUDPConnection.swift | 235 ++++++++++ .../Protocols/QUIC/QUICConnection.swift | 31 +- .../QUIC/QUICDatagramTransport.swift | 9 + .../Protocols/QUIC/QUICTLSHandler.swift | 6 +- .../Protocols/QUIC/QUICTuning.swift | 19 + Shared/Networking/Socket/QUICSocket.swift | 4 +- Shared/Utilities/ClashProxyParser.swift | 22 + Shared/ViewModels/VPNViewModel.swift | 3 + 25 files changed, 1821 insertions(+), 19 deletions(-) create mode 100644 Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereClient.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereConfiguration.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereConnection.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereProtocol.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereSession.swift create mode 100644 Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift 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..925e8a0 100644 --- a/Anywhere TV/TVProxyEditorViewController.swift +++ b/Anywhere TV/TVProxyEditorViewController.swift @@ -61,6 +61,10 @@ 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 = "" + private var nowhereUploadMbpsText = "0" // Trojan fields private var trojanPassword = "" @@ -97,6 +101,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 +130,7 @@ class TVProxyEditorViewController: UITableViewController { case tlsSNI, tlsALPN, fingerprint case realitySNI, realityPublicKey, realityShortId case hysteriaPassword, hysteriaCC, hysteriaUploadMbps, hysteriaDownloadMbps + case nowhereKey, nowhereUploadMbps case trojanPassword case anytlsPassword case ssPassword, ssMethod @@ -149,6 +155,7 @@ class TVProxyEditorViewController: UITableViewController { let protocolOptions: [(String, String)] = [ ("VLESS", "vless"), ("Hysteria", "hysteria"), + ("Nowhere", "nowhere"), ("Trojan", "trojan"), ("AnyTLS", "anytls"), ("Shadowsocks", "shadowsocks"), @@ -182,6 +189,9 @@ 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)) + serverRows.append(.text(label: String(localized: "Upload Speed"), value: nowhereUploadMbpsText, placeholder: String(localized: "Mbps"), key: .nowhereUploadMbps)) } 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 +386,12 @@ class TVProxyEditorViewController: UITableViewController { } return true } + if isNowhere { + guard !nowhereKey.isEmpty, + let up = Int(nowhereUploadMbpsText), HysteriaCongestionControl.uploadMbpsRange.contains(up) + else { return false } + return true + } if isTrojan { return !trojanPassword.isEmpty } if isAnyTLS { return !anytlsPassword.isEmpty } if isShadowsocks { return !ssPassword.isEmpty } @@ -538,7 +554,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 +591,8 @@ class TVProxyEditorViewController: UITableViewController { if let cc = HysteriaCongestionControl(rawValue: value) { hysteriaCC = cc } case .hysteriaUploadMbps: hysteriaUploadMbpsText = value case .hysteriaDownloadMbps: hysteriaDownloadMbpsText = value + case .nowhereKey: nowhereKey = value + case .nowhereUploadMbps: nowhereUploadMbpsText = value case .trojanPassword: trojanPassword = value case .anytlsPassword: anytlsPassword = value case .ssPassword: ssPassword = value @@ -664,6 +682,9 @@ class TVProxyEditorViewController: UITableViewController { hysteriaCC = congestionControl hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) + case .nowhere(let key, let uploadMbps): + nowhereKey = key + nowhereUploadMbpsText = String(uploadMbps) case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -756,7 +777,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 +868,12 @@ class TVProxyEditorViewController: UITableViewController { downloadMbps: down, sni: existingSNI ?? bareAddress ) + case .nowhere: + let up = HysteriaCongestionControl.clampUploadMbps(Int(nowhereUploadMbpsText) ?? 0) + outbound = .nowhere( + key: nowhereKey, + uploadMbps: up + ) 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 71fc7bb..a1b4930 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..3d81245 100644 --- a/Anywhere/Views/ProxyList/ProxyEditorView.swift +++ b/Anywhere/Views/ProxyList/ProxyEditorView.swift @@ -62,6 +62,10 @@ struct ProxyEditorView: View { @State private var hysteriaDownloadMbpsText = String(HysteriaCongestionControl.downloadMbpsDefault) @State private var hysteriaSNI = "" + // Nowhere fields + @State private var nowhereKey = "" + @State private var nowhereUploadMbpsText = "0" + // Trojan fields @State private var trojanPassword = "" @@ -97,6 +101,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 +125,12 @@ struct ProxyEditorView: View { } return true } + if isNowhere { + guard !nowhereKey.isEmpty, + let up = Int(nowhereUploadMbpsText), HysteriaCongestionControl.uploadMbpsRange.contains(up) + else { return false } + return true + } if isTrojan { return !trojanPassword.isEmpty } @@ -166,6 +177,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 +265,23 @@ 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) + } + LabeledContent { + TextField("Mbps", text: $nowhereUploadMbpsText) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } label: { + TextWithColorfulIcon(title: "Upload Speed", comment: nil, systemName: "arrow.up.circle.fill", foregroundColor: .white, backgroundColor: .blue) + } + } else if isTrojan { LabeledContent { SecureField("Password", text: $trojanPassword) .autocorrectionDisabled() @@ -743,6 +771,9 @@ struct ProxyEditorView: View { hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) hysteriaSNI = sni + case .nowhere(let key, let uploadMbps): + nowhereKey = key + nowhereUploadMbpsText = String(uploadMbps) case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -823,7 +854,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 +963,12 @@ struct ProxyEditorView: View { downloadMbps: down, sni: sni ) + case .nowhere: + let up = HysteriaCongestionControl.clampUploadMbps(Int(nowhereUploadMbpsText) ?? 0) + outbound = .nowhere( + key: nowhereKey, + uploadMbps: up + ) 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..220ac22 --- /dev/null +++ b/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift @@ -0,0 +1,144 @@ +// +// ProxyClient+Nowhere.swift +// Anywhere +// +// Created by NodePassProject on 5/29/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, let uploadMbps) = configuration.outbound else { + completion(.failure(ProxyError.protocolError("Nowhere key not set"))) + return + } + + let nwConfig = NowhereConfiguration( + proxyHost: configuration.serverAddress, + proxyPort: configuration.serverPort, + key: key, + uploadMbps: uploadMbps + ) + + 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..c597da6 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift @@ -77,6 +77,12 @@ extension ProxyConfiguration { downloadMbps: HysteriaCongestionControl.clampDownloadMbps(rawDown), sni: (explicitSNI?.isEmpty == false) ? explicitSNI! : serverAddress ) + case .nowhere: + let rawUp = (configurationDict["nowhereUploadMbps"] as? Int) ?? 0 + outbound = .nowhere( + key: (configurationDict["nowhereKey"] as? String) ?? "", + uploadMbps: HysteriaCongestionControl.clampUploadMbps(rawUp) + ) 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..56dcc88 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,16 @@ extension ProxyConfiguration { return "hysteria2://\(encodedPassword)@\(bracketedServerAddress):\(serverPort)/\(query)#\(fragment)" } + private func toNowhereURL() -> String { + guard case .nowhere(let key, let uploadMbps) = outbound else { + return "" + } + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? "" + let fragment = name.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? name + let params = "rate=\(uploadMbps)" + return "nowhere://\(encodedKey)@\(bracketedServerAddress):\(serverPort)?\(params)#\(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..8811abf 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,55 @@ extension ProxyConfiguration { ) ) } + + /// Parse a Nowhere URL. + /// Format: `nowhere://@host:port?rate=20#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, + uploadMbps: configuration.uploadMbps, + 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, + uploadMbps: configuration.uploadMbps, + 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..d45d83b --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereConfiguration.swift @@ -0,0 +1,20 @@ +// +// NowhereConfiguration.swift +// Anywhere +// +// Created by NodePassProject on 5/29/26. +// + +import Foundation + +/// Configuration for a Nowhere QUIC session. +struct NowhereConfiguration: Hashable { + let proxyHost: String + let proxyPort: UInt16 + let key: String + let uploadMbps: Int + + var uploadBytesPerSec: UInt64 { + UInt64(max(0, uploadMbps)) * 1_000_000 / 8 + } +} diff --git a/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift b/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift new file mode 100644 index 0000000..d0eb0e6 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereConnection.swift @@ -0,0 +1,236 @@ +// +// NowhereConnection.swift +// Anywhere +// +// Created by NodePassProject on 5/29/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..b0f34d2 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereProtocol.swift @@ -0,0 +1,190 @@ +// +// NowhereProtocol.swift +// Anywhere +// +// Created by NodePassProject on 5/29/26. +// + +import Foundation +import CryptoKit +import Security + +enum NowhereProtocol { + static let alpn = "nowhere/1" + static let authFrameLength = 80 + static let maxTargetLength = 512 + + private static let authMagic = Data("NWQAUTH1".utf8) + private static let authInfo = Data("nowhere quic auth v1".utf8) + private static let sideMagic = Data([0x00, 0x4E, 0x57, 0x53]) + + 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 + } + + struct SideFrame { + let type: UInt8 + let clientID: UInt64 + let flowID: UInt64 + let target: String + let payload: Data + } + + static func randomClientID() -> UInt64 { + var bytes = [UInt8](repeating: 0, count: 8) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + var value: UInt64 = 0 + for byte in bytes { + value = (value << 8) | UInt64(byte) + } + return value == 0 ? 1 : value + } + + static func makeAuthFrame(key: String, clientID: UInt64) 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") + } + + let clientBytes = uint64Bytes(clientID) + var message = Data() + message.append(authInfo) + message.append(clientBytes) + 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(clientBytes) + 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.. Data { + let targetBytes = try encodeTarget(target) + var out = Data(capacity: 4 + 1 + 8 + 8 + targetBytes.count + payload.count) + out.append(sideMagic) + out.append(type.rawValue) + out.append(uint64Bytes(clientID)) + out.append(uint64Bytes(flowID)) + out.append(targetBytes) + out.append(payload) + return out + } + + static func decodeSideFrame(_ data: Data) -> SideFrame? { + guard data.count >= 23, data.prefix(sideMagic.count) == sideMagic else { return nil } + let type = byte(data, at: 4) + guard type == UDPType.response.rawValue || type == UDPType.close.rawValue else { return nil } + let clientID = readUInt64(data, at: 5) + let flowID = readUInt64(data, at: 13) + guard let parsed = decodeTarget(data, offset: 21) else { return nil } + let payload = data.subdata(in: parsed.nextOffset.. Bool { + data.count >= sideMagic.count && data.prefix(sideMagic.count) == sideMagic + } + + static func isQUICLongHeader(_ data: Data) -> Bool { + guard !data.isEmpty else { return false } + return (byte(data, at: 0) & 0x80) != 0 + } + + static func isQUICShortHeader(_ data: Data) -> Bool { + guard !data.isEmpty else { return false } + let first = byte(data, at: 0) + return (first & 0x80) == 0 && (first & 0x40) != 0 + } + + static func udpHeaderSize(target: String) -> Int { + 1 + 8 + 2 + target.utf8.count + } + + static func sideHeaderSize(target: String) -> Int { + 4 + 1 + 8 + 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..5616291 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereSession.swift @@ -0,0 +1,414 @@ +// +// NowhereSession.swift +// Anywhere +// +// Created by NodePassProject on 5/29/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 let clientID: UInt64 + 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.clientID = NowhereProtocol.randomClientID() + self.quic = QUICConnection( + host: configuration.proxyHost, + port: configuration.proxyPort, + serverName: configuration.proxyHost, + alpn: [NowhereProtocol.alpn], + datagramsEnabled: true, + tuning: .nowhere(uploadMbps: configuration.uploadMbps), + 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.nonQUICPacketHandler = { [weak self] data in + self?.handleNonQUICPacket(data) ?? false + } + + 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, clientID: clientID) + } 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) + } + + private func handleNonQUICPacket(_ data: Data) -> Bool { + guard NowhereProtocol.isSideFrame(data) else { return false } + guard let frame = NowhereProtocol.decodeSideFrame(data), + frame.type == NowhereProtocol.UDPType.response.rawValue, + frame.clientID == clientID else { + return true + } + udpSessions[frame.flowID]?.handleIncomingDatagram(frame.payload) + return true + } + + 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) + } + + func writeSideFrame(_ frame: Data, completion: @escaping (Error?) -> Void) { + quic.writeRawPacket(frame, completion: completion) + } + + var maxDatagramPayloadSize: Int { + quic.maxDatagramPayloadSize + } + + var canUseSidePath: Bool { + quic.canSendRawPackets + } + + var clientIDValue: UInt64 { clientID } + + 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..63f6909 --- /dev/null +++ b/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift @@ -0,0 +1,235 @@ +// +// NowhereUDPConnection.swift +// Anywhere +// +// Created by NodePassProject on 5/29/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? + + /// Once a long-header QUIC packet has identified this UDP flow as an + /// inner QUIC conversation, short-header packets use Nowhere's raw side + /// path when direct UDP is available. + private var innerQUICFlow = false + + 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.sendPayload(data, completion: completion) + } + } + + override func sendRaw(data: Data) { + sendRaw(data: data) { _ in } + } + + private func sendPayload(_ payload: Data, completion: @escaping (Error?) -> Void) { + if shouldUseSidePath(for: payload) { + sendSidePayload(payload, completion: completion) + return + } + sendDatagramPayload(payload, completion: completion) + } + + private func shouldUseSidePath(for payload: Data) -> Bool { + if NowhereProtocol.isQUICLongHeader(payload) { + innerQUICFlow = true + return false + } + return innerQUICFlow && NowhereProtocol.isQUICShortHeader(payload) && session.canUseSidePath + } + + 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) + } + + private func sendSidePayload(_ payload: Data, completion: @escaping (Error?) -> Void) { + let headerSize = NowhereProtocol.sideHeaderSize(target: destination) + guard headerSize + payload.count <= QUICConnection.maxUDPPayload else { + completion(NowhereError.destinationTooLargeForDatagram( + maxFrame: QUICConnection.maxUDPPayload, + headerSize: headerSize + )) + return + } + + let frame: Data + do { + frame = try NowhereProtocol.encodeSideFrame( + type: .request, + clientID: session.clientIDValue, + flowID: flowID, + target: destination, + payload: payload + ) + } catch { + completion(error) + return + } + session.writeSideFrame(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/QUICConnection.swift b/Shared/Networking/Protocols/QUIC/QUICConnection.swift index 98c0e06..341d5fd 100644 --- a/Shared/Networking/Protocols/QUIC/QUICConnection.swift +++ b/Shared/Networking/Protocols/QUIC/QUICConnection.swift @@ -139,6 +139,9 @@ nonisolated class QUICConnection { var streamTerminationHandler: ((Int64, Error?) -> Void)? /// Called when a QUIC DATAGRAM frame is received. var datagramHandler: ((Data) -> Void)? + /// Gives a protocol layer first refusal on raw UDP packets that are not + /// meant for ngtcp2, such as Nowhere's side-path UDP frames. + var nonQUICPacketHandler: ((Data) -> Bool)? /// Called when the QUIC connection is closed (draining, error, etc.). /// Allows the session to react immediately rather than discovering it on the next operation. var connectionClosedHandler: ((Error) -> Void)? @@ -541,6 +544,29 @@ nonisolated class QUICConnection { return min(frameLimit, pathLimit) } + var canSendRawPackets: Bool { + dispatchPrecondition(condition: .onQueue(queue)) + return transport == nil && quicSocket != nil && state == .connected + } + + func writeRawPacket(_ data: Data, completion: ((Error?) -> Void)? = nil) { + queue.async { [weak self] in + guard let self else { + completion?(QUICError.closed) + return + } + guard self.transport == nil, let quicSocket = self.quicSocket, self.state == .connected else { + completion?(QUICError.connectionFailed("Raw packet side path unavailable")) + return + } + data.withUnsafeBytes { raw in + guard let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + quicSocket.send(ptr, length: data.count) + } + completion?(nil) + } + } + /// Writes stream data, queuing any remainder that can't be sent due to /// flow control. Queued data is flushed when incoming packets extend the /// window (MAX_STREAM_DATA). @@ -828,7 +854,7 @@ nonisolated class QUICConnection { guard remoteAddr.ss_family != 0 else { throw QUICError.connectionFailed("DNS lookup failed for \(host)") } - let sock = QUICSocket(queue: queue, receiveBufferSize: Self.maxUDPPayload) + let sock = QUICSocket(queue: queue) try sock.connect(remoteAddr: remoteAddr, localAddr: &localAddr, addrLen: addrLen) quicSocket = sock try initializeNgtcp2() @@ -1194,6 +1220,9 @@ nonisolated class QUICConnection { // MARK: Packet Processing fileprivate func handleReceivedPacket(_ data: Data) { + if nonQUICPacketHandler?(data) == true { + return + } guard let conn else { return } let ts = currentTimestamp() var pi = ngtcp2_pkt_info() 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/QUICTLSHandler.swift b/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift index 6472ce3..ea2334e 100644 --- a/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift +++ b/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift @@ -496,7 +496,7 @@ nonisolated class QUICTLSHandler { return .error(NGTCP2_ERR_CALLBACK_FAILURE) } - // Validate server certificate chain (respects allowInsecure setting) + // Validate server certificate chain (respects CertificatePolicy). if let error = validateCertificate() { logger.warning("[QUIC-TLS] Certificate validation failed: \(error.localizedDescription)") return .error(NGTCP2_ERR_CALLBACK_FAILURE) @@ -881,7 +881,7 @@ nonisolated class QUICTLSHandler { // MARK: - Certificate Validation /// Validates the server certificate chain using SecTrust. - /// Respects `allowInsecure` and user-trusted certificate SHA-256 fingerprints, + /// Respects `CertificatePolicy.allowInsecure` and user-trusted certificate SHA-256 fingerprints, /// matching the behavior of TLSClient for HTTP/2. private func validateCertificate() -> Error? { if CertificatePolicy.allowInsecure { @@ -958,7 +958,7 @@ nonisolated class QUICTLSHandler { ) if !isValid { - // Respect allowInsecure for signature verification too + // Respect CertificatePolicy for signature verification too. if CertificatePolicy.allowInsecure { return nil } diff --git a/Shared/Networking/Protocols/QUIC/QUICTuning.swift b/Shared/Networking/Protocols/QUIC/QUICTuning.swift index 26025fa..7327219 100644 --- a/Shared/Networking/Protocols/QUIC/QUICTuning.swift +++ b/Shared/Networking/Protocols/QUIC/QUICTuning.swift @@ -160,4 +160,23 @@ extension QUICTuning { ) } } + + static func nowhere(uploadMbps: Int) -> QUICTuning { + let bps = UInt64(max(0, uploadMbps)) * 1_000_000 / 8 + return QUICTuning( + cc: bps > 0 ? .brutal(initialBps: bps) : .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/Networking/Socket/QUICSocket.swift b/Shared/Networking/Socket/QUICSocket.swift index b8523e7..9b1dc3a 100644 --- a/Shared/Networking/Socket/QUICSocket.swift +++ b/Shared/Networking/Socket/QUICSocket.swift @@ -56,9 +56,9 @@ nonisolated final class QUICSocket { /// True while the socket FD is open. var isOpen: Bool { socketFD >= 0 } - init(queue: DispatchQueue, receiveBufferSize: Int) { + init(queue: DispatchQueue) { self.queue = queue - self.rxBuf = [UInt8](repeating: 0, count: receiveBufferSize) + self.rxBuf = [UInt8](repeating: 0, count: QUICConnection.maxUDPPayload) } #if DEBUG diff --git a/Shared/Utilities/ClashProxyParser.swift b/Shared/Utilities/ClashProxyParser.swift index 3a63bcd..9f877a1 100644 --- a/Shared/Utilities/ClashProxyParser.swift +++ b/Shared/Utilities/ClashProxyParser.swift @@ -68,6 +68,7 @@ struct ClashProxyParser { switch type { case "vless": return parseVLESSProxy(node) case "hysteria2": return parseHysteria2Proxy(node) + case "nowhere": return parseNowhereProxy(node) case "trojan": return parseTrojanProxy(node) case "anytls": return parseAnyTLSProxy(node) case "ss": return parseShadowsocksProxy(node) @@ -232,6 +233,27 @@ struct ClashProxyParser { ) } + /// Parses a Clash-style Nowhere node. This is not a standard Clash type, + /// but keeps Nowhere subscription imports round-trippable. + private static func parseNowhereProxy(_ node: Node) -> ProxyConfiguration? { + guard let basics = parseBasics(node) else { return nil } + let key = getString(node, key: "key") ?? getString(node, key: "password") ?? "" + guard !key.isEmpty else { return nil } + let uploadMbps = HysteriaCongestionControl.clampUploadMbps( + parseBandwidthMbps(getString(node, key: "rate") ?? getString(node, key: "up"), default: 0) + ) + + return ProxyConfiguration( + name: basics.name, + serverAddress: basics.server, + serverPort: basics.port, + outbound: .nowhere( + key: key, + uploadMbps: uploadMbps + ) + ) + } + // MARK: - Trojan /// Parses a Clash `type: trojan` node into a `.trojan(password:tls:)` diff --git a/Shared/ViewModels/VPNViewModel.swift b/Shared/ViewModels/VPNViewModel.swift index 4917bd3..4e0e4de 100644 --- a/Shared/ViewModels/VPNViewModel.swift +++ b/Shared/ViewModels/VPNViewModel.swift @@ -817,6 +817,9 @@ class VPNViewModel: ObservableObject { configurationDict["hysteriaUploadMbps"] = uploadMbps configurationDict["hysteriaDownloadMbps"] = downloadMbps configurationDict["hysteriaSNI"] = sni + case .nowhere(let key, let uploadMbps): + configurationDict["nowhereKey"] = key + configurationDict["nowhereUploadMbps"] = uploadMbps case .trojan(let password, let tls): configurationDict["trojanPassword"] = password configurationDict["trojanSNI"] = tls.serverName From 415e70777e0df29a175645ad6fa813bc56f49af6 Mon Sep 17 00:00:00 2001 From: yosebyte Date: Sat, 30 May 2026 07:05:26 +0000 Subject: [PATCH 2/2] Refactor Nowhere protocol to simplify configuration --- Anywhere TV/TVProxyEditorViewController.swift | 17 ++--- .../Views/ProxyList/ProxyEditorView.swift | 20 +----- .../Protocols/Core/ProxyClient+Nowhere.swift | 7 +- .../Core/ProxyConfiguration+DictParsing.swift | 4 +- .../Core/ProxyConfiguration+URLExport.swift | 5 +- .../Core/ProxyConfiguration+URLParsing.swift | 9 +-- .../Protocols/Core/ProxyConfiguration.swift | 18 ++--- .../Protocols/Nowhere/NowhereClient.swift | 5 +- .../Nowhere/NowhereConfiguration.swift | 7 +- .../Protocols/Nowhere/NowhereConnection.swift | 2 +- .../Protocols/Nowhere/NowhereProtocol.swift | 70 +------------------ .../Protocols/Nowhere/NowhereSession.swift | 32 +-------- .../Nowhere/NowhereUDPConnection.swift | 51 +------------- .../Protocols/QUIC/QUICConnection.swift | 31 +------- .../Protocols/QUIC/QUICTLSHandler.swift | 6 +- .../Protocols/QUIC/QUICTuning.swift | 33 ++++----- Shared/Networking/Socket/QUICSocket.swift | 4 +- Shared/Utilities/ClashProxyParser.swift | 22 ------ Shared/ViewModels/VPNViewModel.swift | 3 +- 19 files changed, 53 insertions(+), 293 deletions(-) diff --git a/Anywhere TV/TVProxyEditorViewController.swift b/Anywhere TV/TVProxyEditorViewController.swift index 925e8a0..4bda42d 100644 --- a/Anywhere TV/TVProxyEditorViewController.swift +++ b/Anywhere TV/TVProxyEditorViewController.swift @@ -64,7 +64,6 @@ class TVProxyEditorViewController: UITableViewController { // Nowhere fields private var nowhereKey = "" - private var nowhereUploadMbpsText = "0" // Trojan fields private var trojanPassword = "" @@ -130,7 +129,7 @@ class TVProxyEditorViewController: UITableViewController { case tlsSNI, tlsALPN, fingerprint case realitySNI, realityPublicKey, realityShortId case hysteriaPassword, hysteriaCC, hysteriaUploadMbps, hysteriaDownloadMbps - case nowhereKey, nowhereUploadMbps + case nowhereKey case trojanPassword case anytlsPassword case ssPassword, ssMethod @@ -191,7 +190,6 @@ class TVProxyEditorViewController: UITableViewController { } } else if isNowhere { serverRows.append(.text(label: String(localized: "Key"), value: nowhereKey, placeholder: String(localized: "Key"), key: .nowhereKey, secure: true)) - serverRows.append(.text(label: String(localized: "Upload Speed"), value: nowhereUploadMbpsText, placeholder: String(localized: "Mbps"), key: .nowhereUploadMbps)) } else if isTrojan { serverRows.append(.text(label: String(localized: "Password"), value: trojanPassword, placeholder: String(localized: "Password"), key: .trojanPassword, secure: true)) } else if isAnyTLS { @@ -387,10 +385,7 @@ class TVProxyEditorViewController: UITableViewController { return true } if isNowhere { - guard !nowhereKey.isEmpty, - let up = Int(nowhereUploadMbpsText), HysteriaCongestionControl.uploadMbpsRange.contains(up) - else { return false } - return true + return !nowhereKey.isEmpty } if isTrojan { return !trojanPassword.isEmpty } if isAnyTLS { return !anytlsPassword.isEmpty } @@ -592,7 +587,6 @@ class TVProxyEditorViewController: UITableViewController { case .hysteriaUploadMbps: hysteriaUploadMbpsText = value case .hysteriaDownloadMbps: hysteriaDownloadMbpsText = value case .nowhereKey: nowhereKey = value - case .nowhereUploadMbps: nowhereUploadMbpsText = value case .trojanPassword: trojanPassword = value case .anytlsPassword: anytlsPassword = value case .ssPassword: ssPassword = value @@ -682,9 +676,8 @@ class TVProxyEditorViewController: UITableViewController { hysteriaCC = congestionControl hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) - case .nowhere(let key, let uploadMbps): + case .nowhere(let key): nowhereKey = key - nowhereUploadMbpsText = String(uploadMbps) case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -869,10 +862,8 @@ class TVProxyEditorViewController: UITableViewController { sni: existingSNI ?? bareAddress ) case .nowhere: - let up = HysteriaCongestionControl.clampUploadMbps(Int(nowhereUploadMbpsText) ?? 0) outbound = .nowhere( - key: nowhereKey, - uploadMbps: up + key: nowhereKey ) case .trojan: let sniValue = tlsSNI.isEmpty ? bareAddress : tlsSNI diff --git a/Anywhere/Views/ProxyList/ProxyEditorView.swift b/Anywhere/Views/ProxyList/ProxyEditorView.swift index 3d81245..db06188 100644 --- a/Anywhere/Views/ProxyList/ProxyEditorView.swift +++ b/Anywhere/Views/ProxyList/ProxyEditorView.swift @@ -64,7 +64,6 @@ struct ProxyEditorView: View { // Nowhere fields @State private var nowhereKey = "" - @State private var nowhereUploadMbpsText = "0" // Trojan fields @State private var trojanPassword = "" @@ -126,10 +125,7 @@ struct ProxyEditorView: View { return true } if isNowhere { - guard !nowhereKey.isEmpty, - let up = Int(nowhereUploadMbpsText), HysteriaCongestionControl.uploadMbpsRange.contains(up) - else { return false } - return true + return !nowhereKey.isEmpty } if isTrojan { return !trojanPassword.isEmpty @@ -274,13 +270,6 @@ struct ProxyEditorView: View { } label: { TextWithColorfulIcon(title: "Key", comment: nil, systemName: "key.fill", foregroundColor: .white, backgroundColor: .green) } - LabeledContent { - TextField("Mbps", text: $nowhereUploadMbpsText) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - } label: { - TextWithColorfulIcon(title: "Upload Speed", comment: nil, systemName: "arrow.up.circle.fill", foregroundColor: .white, backgroundColor: .blue) - } } else if isTrojan { LabeledContent { SecureField("Password", text: $trojanPassword) @@ -771,9 +760,8 @@ struct ProxyEditorView: View { hysteriaUploadMbpsText = String(uploadMbps) hysteriaDownloadMbpsText = String(downloadMbps) hysteriaSNI = sni - case .nowhere(let key, let uploadMbps): + case .nowhere(let key): nowhereKey = key - nowhereUploadMbpsText = String(uploadMbps) case .trojan(let password, let tls): trojanPassword = password tlsSNI = tls.serverName @@ -964,10 +952,8 @@ struct ProxyEditorView: View { sni: sni ) case .nowhere: - let up = HysteriaCongestionControl.clampUploadMbps(Int(nowhereUploadMbpsText) ?? 0) outbound = .nowhere( - key: nowhereKey, - uploadMbps: up + key: nowhereKey ) case .trojan: let sni = tlsSNI.isEmpty ? bareAddress : tlsSNI diff --git a/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift b/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift index 220ac22..81db47d 100644 --- a/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift +++ b/Shared/Networking/Protocols/Core/ProxyClient+Nowhere.swift @@ -2,7 +2,7 @@ // ProxyClient+Nowhere.swift // Anywhere // -// Created by NodePassProject on 5/29/26. +// Created by NodePassProject on 5/30/26. // import Foundation @@ -17,7 +17,7 @@ extension ProxyClient { destinationPort: UInt16, completion: @escaping (Result) -> Void ) { - guard case .nowhere(let key, let uploadMbps) = configuration.outbound else { + guard case .nowhere(let key) = configuration.outbound else { completion(.failure(ProxyError.protocolError("Nowhere key not set"))) return } @@ -25,8 +25,7 @@ extension ProxyClient { let nwConfig = NowhereConfiguration( proxyHost: configuration.serverAddress, proxyPort: configuration.serverPort, - key: key, - uploadMbps: uploadMbps + key: key ) let bracketedHost = destinationHost.contains(":") ? "[\(destinationHost)]" : destinationHost diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift index c597da6..378ac31 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+DictParsing.swift @@ -78,10 +78,8 @@ extension ProxyConfiguration { sni: (explicitSNI?.isEmpty == false) ? explicitSNI! : serverAddress ) case .nowhere: - let rawUp = (configurationDict["nowhereUploadMbps"] as? Int) ?? 0 outbound = .nowhere( - key: (configurationDict["nowhereKey"] as? String) ?? "", - uploadMbps: HysteriaCongestionControl.clampUploadMbps(rawUp) + key: (configurationDict["nowhereKey"] as? String) ?? "" ) case .trojan: let password = (configurationDict["trojanPassword"] as? String) ?? "" diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift index 56dcc88..21f8349 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLExport.swift @@ -119,13 +119,12 @@ extension ProxyConfiguration { } private func toNowhereURL() -> String { - guard case .nowhere(let key, let uploadMbps) = outbound else { + guard case .nowhere(let key) = outbound else { return "" } let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? "" let fragment = name.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? name - let params = "rate=\(uploadMbps)" - return "nowhere://\(encodedKey)@\(bracketedServerAddress):\(serverPort)?\(params)#\(fragment)" + return "nowhere://\(encodedKey)@\(bracketedServerAddress):\(serverPort)#\(fragment)" } private func toTrojanURL() -> String { diff --git a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift index 8811abf..4530006 100644 --- a/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift +++ b/Shared/Networking/Protocols/Core/ProxyConfiguration+URLParsing.swift @@ -220,7 +220,7 @@ extension ProxyConfiguration { } /// Parse a Nowhere URL. - /// Format: `nowhere://@host:port?rate=20#name` + /// Format: `nowhere://@host:port#name` private static func parseNowhere(url: String) throws -> ProxyConfiguration { let rawPrefix = "nowhere://" var remaining = String(url.dropFirst(rawPrefix.count)) @@ -232,9 +232,7 @@ extension ProxyConfiguration { } DeviceCensorship.deCensor(&fragmentName) - var queryString: String? if let questionIndex = remaining.firstIndex(of: "?") { - queryString = String(remaining[remaining.index(after: questionIndex)...]) remaining = String(remaining[.. UInt64 { - var bytes = [UInt8](repeating: 0, count: 8) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - var value: UInt64 = 0 - for byte in bytes { - value = (value << 8) | UInt64(byte) - } - return value == 0 ? 1 : value - } - - static func makeAuthFrame(key: String, clientID: UInt64) throws -> 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 } @@ -59,10 +40,8 @@ enum NowhereProtocol { throw NowhereError.connectionFailed("Failed to generate auth nonce") } - let clientBytes = uint64Bytes(clientID) var message = Data() message.append(authInfo) - message.append(clientBytes) message.append(nonce) let derived = Data(SHA256.hash(data: Data(key.utf8))) @@ -73,7 +52,6 @@ enum NowhereProtocol { var frame = Data(capacity: authFrameLength) frame.append(authMagic) - frame.append(clientBytes) frame.append(nonce) frame.append(contentsOf: tag) return frame @@ -103,52 +81,10 @@ enum NowhereProtocol { return UDPMessage(type: type, flowID: flowID, target: parsed.target, payload: payload) } - static func encodeSideFrame(type: UDPType, clientID: UInt64, flowID: UInt64, target: String, payload: Data) throws -> Data { - let targetBytes = try encodeTarget(target) - var out = Data(capacity: 4 + 1 + 8 + 8 + targetBytes.count + payload.count) - out.append(sideMagic) - out.append(type.rawValue) - out.append(uint64Bytes(clientID)) - out.append(uint64Bytes(flowID)) - out.append(targetBytes) - out.append(payload) - return out - } - - static func decodeSideFrame(_ data: Data) -> SideFrame? { - guard data.count >= 23, data.prefix(sideMagic.count) == sideMagic else { return nil } - let type = byte(data, at: 4) - guard type == UDPType.response.rawValue || type == UDPType.close.rawValue else { return nil } - let clientID = readUInt64(data, at: 5) - let flowID = readUInt64(data, at: 13) - guard let parsed = decodeTarget(data, offset: 21) else { return nil } - let payload = data.subdata(in: parsed.nextOffset.. Bool { - data.count >= sideMagic.count && data.prefix(sideMagic.count) == sideMagic - } - - static func isQUICLongHeader(_ data: Data) -> Bool { - guard !data.isEmpty else { return false } - return (byte(data, at: 0) & 0x80) != 0 - } - - static func isQUICShortHeader(_ data: Data) -> Bool { - guard !data.isEmpty else { return false } - let first = byte(data, at: 0) - return (first & 0x80) == 0 && (first & 0x40) != 0 - } - static func udpHeaderSize(target: String) -> Int { 1 + 8 + 2 + target.utf8.count } - static func sideHeaderSize(target: String) -> Int { - 4 + 1 + 8 + 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 { diff --git a/Shared/Networking/Protocols/Nowhere/NowhereSession.swift b/Shared/Networking/Protocols/Nowhere/NowhereSession.swift index 5616291..1ad13e3 100644 --- a/Shared/Networking/Protocols/Nowhere/NowhereSession.swift +++ b/Shared/Networking/Protocols/Nowhere/NowhereSession.swift @@ -2,7 +2,7 @@ // NowhereSession.swift // Anywhere // -// Created by NodePassProject on 5/29/26. +// Created by NodePassProject on 5/30/26. // import Foundation @@ -42,7 +42,6 @@ nonisolated final class NowhereSession { private let closeLatch = CloseOnce() private var authStreamID: Int64 = -1 - private let clientID: UInt64 private var readyCallbacks: [(Error?) -> Void] = [] var onClose: (() -> Void)? @@ -73,14 +72,13 @@ nonisolated final class NowhereSession { init(configuration: NowhereConfiguration, transport: QUICDatagramTransport? = nil) { self.configuration = configuration - self.clientID = NowhereProtocol.randomClientID() self.quic = QUICConnection( host: configuration.proxyHost, port: configuration.proxyPort, serverName: configuration.proxyHost, alpn: [NowhereProtocol.alpn], datagramsEnabled: true, - tuning: .nowhere(uploadMbps: configuration.uploadMbps), + tuning: .nowhere, transport: transport ) } @@ -124,9 +122,6 @@ nonisolated final class NowhereSession { quic.datagramHandler = { [weak self] data in self?.handleDatagram(data) } - quic.nonQUICPacketHandler = { [weak self] data in - self?.handleNonQUICPacket(data) ?? false - } quic.connect { [weak self] error in guard let self else { return } @@ -150,7 +145,7 @@ nonisolated final class NowhereSession { let frame: Data do { - frame = try NowhereProtocol.makeAuthFrame(key: configuration.key, clientID: clientID) + frame = try NowhereProtocol.makeAuthFrame(key: configuration.key) } catch { failSession(error) return @@ -215,17 +210,6 @@ nonisolated final class NowhereSession { udpSessions[msg.flowID]?.handleIncomingDatagram(msg.payload) } - private func handleNonQUICPacket(_ data: Data) -> Bool { - guard NowhereProtocol.isSideFrame(data) else { return false } - guard let frame = NowhereProtocol.decodeSideFrame(data), - frame.type == NowhereProtocol.UDPType.response.rawValue, - frame.clientID == clientID else { - return true - } - udpSessions[frame.flowID]?.handleIncomingDatagram(frame.payload) - return true - } - func openTCPStream(for conn: NowhereConnection, completion: @escaping (Int64?, Error?) -> Void) { queue.async { [weak self] in guard let self else { completion(nil, NowhereError.streamClosed); return } @@ -315,20 +299,10 @@ nonisolated final class NowhereSession { quic.writeDatagram(datagram, completion: completion) } - func writeSideFrame(_ frame: Data, completion: @escaping (Error?) -> Void) { - quic.writeRawPacket(frame, completion: completion) - } - var maxDatagramPayloadSize: Int { quic.maxDatagramPayloadSize } - var canUseSidePath: Bool { - quic.canSendRawPackets - } - - var clientIDValue: UInt64 { clientID } - private func updateIdleCloseTimer() { idleCloseWorkItem?.cancel() idleCloseWorkItem = nil diff --git a/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift b/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift index 63f6909..413f9b7 100644 --- a/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift +++ b/Shared/Networking/Protocols/Nowhere/NowhereUDPConnection.swift @@ -2,7 +2,7 @@ // NowhereUDPConnection.swift // Anywhere // -// Created by NodePassProject on 5/29/26. +// Created by NodePassProject on 5/30/26. // import Foundation @@ -31,11 +31,6 @@ nonisolated final class NowhereUDPConnection: ProxyConnection { private var pendingReceive: ((Data?, Error?) -> Void)? private var closureError: Error? - /// Once a long-header QUIC packet has identified this UDP flow as an - /// inner QUIC conversation, short-header packets use Nowhere's raw side - /// path when direct UDP is available. - private var innerQUICFlow = false - init(session: NowhereSession, destination: String) { self.session = session self.destination = destination @@ -90,7 +85,7 @@ nonisolated final class NowhereUDPConnection: ProxyConnection { completion(self.state == .closed ? NowhereError.streamClosed : NowhereError.notReady) return } - self.sendPayload(data, completion: completion) + self.sendDatagramPayload(data, completion: completion) } } @@ -98,22 +93,6 @@ nonisolated final class NowhereUDPConnection: ProxyConnection { sendRaw(data: data) { _ in } } - private func sendPayload(_ payload: Data, completion: @escaping (Error?) -> Void) { - if shouldUseSidePath(for: payload) { - sendSidePayload(payload, completion: completion) - return - } - sendDatagramPayload(payload, completion: completion) - } - - private func shouldUseSidePath(for payload: Data) -> Bool { - if NowhereProtocol.isQUICLongHeader(payload) { - innerQUICFlow = true - return false - } - return innerQUICFlow && NowhereProtocol.isQUICShortHeader(payload) && session.canUseSidePath - } - private func sendDatagramPayload(_ payload: Data, completion: @escaping (Error?) -> Void) { let maxSize = session.maxDatagramPayloadSize let headerSize = NowhereProtocol.udpHeaderSize(target: destination) @@ -141,32 +120,6 @@ nonisolated final class NowhereUDPConnection: ProxyConnection { session.writeDatagram(frame, completion: completion) } - private func sendSidePayload(_ payload: Data, completion: @escaping (Error?) -> Void) { - let headerSize = NowhereProtocol.sideHeaderSize(target: destination) - guard headerSize + payload.count <= QUICConnection.maxUDPPayload else { - completion(NowhereError.destinationTooLargeForDatagram( - maxFrame: QUICConnection.maxUDPPayload, - headerSize: headerSize - )) - return - } - - let frame: Data - do { - frame = try NowhereProtocol.encodeSideFrame( - type: .request, - clientID: session.clientIDValue, - flowID: flowID, - target: destination, - payload: payload - ) - } catch { - completion(error) - return - } - session.writeSideFrame(frame, completion: completion) - } - override func receiveRaw(completion: @escaping (Data?, Error?) -> Void) { session.queue.async { [weak self] in guard let self else { diff --git a/Shared/Networking/Protocols/QUIC/QUICConnection.swift b/Shared/Networking/Protocols/QUIC/QUICConnection.swift index 341d5fd..98c0e06 100644 --- a/Shared/Networking/Protocols/QUIC/QUICConnection.swift +++ b/Shared/Networking/Protocols/QUIC/QUICConnection.swift @@ -139,9 +139,6 @@ nonisolated class QUICConnection { var streamTerminationHandler: ((Int64, Error?) -> Void)? /// Called when a QUIC DATAGRAM frame is received. var datagramHandler: ((Data) -> Void)? - /// Gives a protocol layer first refusal on raw UDP packets that are not - /// meant for ngtcp2, such as Nowhere's side-path UDP frames. - var nonQUICPacketHandler: ((Data) -> Bool)? /// Called when the QUIC connection is closed (draining, error, etc.). /// Allows the session to react immediately rather than discovering it on the next operation. var connectionClosedHandler: ((Error) -> Void)? @@ -544,29 +541,6 @@ nonisolated class QUICConnection { return min(frameLimit, pathLimit) } - var canSendRawPackets: Bool { - dispatchPrecondition(condition: .onQueue(queue)) - return transport == nil && quicSocket != nil && state == .connected - } - - func writeRawPacket(_ data: Data, completion: ((Error?) -> Void)? = nil) { - queue.async { [weak self] in - guard let self else { - completion?(QUICError.closed) - return - } - guard self.transport == nil, let quicSocket = self.quicSocket, self.state == .connected else { - completion?(QUICError.connectionFailed("Raw packet side path unavailable")) - return - } - data.withUnsafeBytes { raw in - guard let ptr = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - quicSocket.send(ptr, length: data.count) - } - completion?(nil) - } - } - /// Writes stream data, queuing any remainder that can't be sent due to /// flow control. Queued data is flushed when incoming packets extend the /// window (MAX_STREAM_DATA). @@ -854,7 +828,7 @@ nonisolated class QUICConnection { guard remoteAddr.ss_family != 0 else { throw QUICError.connectionFailed("DNS lookup failed for \(host)") } - let sock = QUICSocket(queue: queue) + let sock = QUICSocket(queue: queue, receiveBufferSize: Self.maxUDPPayload) try sock.connect(remoteAddr: remoteAddr, localAddr: &localAddr, addrLen: addrLen) quicSocket = sock try initializeNgtcp2() @@ -1220,9 +1194,6 @@ nonisolated class QUICConnection { // MARK: Packet Processing fileprivate func handleReceivedPacket(_ data: Data) { - if nonQUICPacketHandler?(data) == true { - return - } guard let conn else { return } let ts = currentTimestamp() var pi = ngtcp2_pkt_info() diff --git a/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift b/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift index ea2334e..6472ce3 100644 --- a/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift +++ b/Shared/Networking/Protocols/QUIC/QUICTLSHandler.swift @@ -496,7 +496,7 @@ nonisolated class QUICTLSHandler { return .error(NGTCP2_ERR_CALLBACK_FAILURE) } - // Validate server certificate chain (respects CertificatePolicy). + // Validate server certificate chain (respects allowInsecure setting) if let error = validateCertificate() { logger.warning("[QUIC-TLS] Certificate validation failed: \(error.localizedDescription)") return .error(NGTCP2_ERR_CALLBACK_FAILURE) @@ -881,7 +881,7 @@ nonisolated class QUICTLSHandler { // MARK: - Certificate Validation /// Validates the server certificate chain using SecTrust. - /// Respects `CertificatePolicy.allowInsecure` and user-trusted certificate SHA-256 fingerprints, + /// Respects `allowInsecure` and user-trusted certificate SHA-256 fingerprints, /// matching the behavior of TLSClient for HTTP/2. private func validateCertificate() -> Error? { if CertificatePolicy.allowInsecure { @@ -958,7 +958,7 @@ nonisolated class QUICTLSHandler { ) if !isValid { - // Respect CertificatePolicy for signature verification too. + // Respect allowInsecure for signature verification too if CertificatePolicy.allowInsecure { return nil } diff --git a/Shared/Networking/Protocols/QUIC/QUICTuning.swift b/Shared/Networking/Protocols/QUIC/QUICTuning.swift index 7327219..4c3e833 100644 --- a/Shared/Networking/Protocols/QUIC/QUICTuning.swift +++ b/Shared/Networking/Protocols/QUIC/QUICTuning.swift @@ -161,22 +161,19 @@ extension QUICTuning { } } - static func nowhere(uploadMbps: Int) -> QUICTuning { - let bps = UInt64(max(0, uploadMbps)) * 1_000_000 / 8 - return QUICTuning( - cc: bps > 0 ? .brutal(initialBps: bps) : .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 - ) - } + 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/Networking/Socket/QUICSocket.swift b/Shared/Networking/Socket/QUICSocket.swift index 9b1dc3a..b8523e7 100644 --- a/Shared/Networking/Socket/QUICSocket.swift +++ b/Shared/Networking/Socket/QUICSocket.swift @@ -56,9 +56,9 @@ nonisolated final class QUICSocket { /// True while the socket FD is open. var isOpen: Bool { socketFD >= 0 } - init(queue: DispatchQueue) { + init(queue: DispatchQueue, receiveBufferSize: Int) { self.queue = queue - self.rxBuf = [UInt8](repeating: 0, count: QUICConnection.maxUDPPayload) + self.rxBuf = [UInt8](repeating: 0, count: receiveBufferSize) } #if DEBUG diff --git a/Shared/Utilities/ClashProxyParser.swift b/Shared/Utilities/ClashProxyParser.swift index 9f877a1..3a63bcd 100644 --- a/Shared/Utilities/ClashProxyParser.swift +++ b/Shared/Utilities/ClashProxyParser.swift @@ -68,7 +68,6 @@ struct ClashProxyParser { switch type { case "vless": return parseVLESSProxy(node) case "hysteria2": return parseHysteria2Proxy(node) - case "nowhere": return parseNowhereProxy(node) case "trojan": return parseTrojanProxy(node) case "anytls": return parseAnyTLSProxy(node) case "ss": return parseShadowsocksProxy(node) @@ -233,27 +232,6 @@ struct ClashProxyParser { ) } - /// Parses a Clash-style Nowhere node. This is not a standard Clash type, - /// but keeps Nowhere subscription imports round-trippable. - private static func parseNowhereProxy(_ node: Node) -> ProxyConfiguration? { - guard let basics = parseBasics(node) else { return nil } - let key = getString(node, key: "key") ?? getString(node, key: "password") ?? "" - guard !key.isEmpty else { return nil } - let uploadMbps = HysteriaCongestionControl.clampUploadMbps( - parseBandwidthMbps(getString(node, key: "rate") ?? getString(node, key: "up"), default: 0) - ) - - return ProxyConfiguration( - name: basics.name, - serverAddress: basics.server, - serverPort: basics.port, - outbound: .nowhere( - key: key, - uploadMbps: uploadMbps - ) - ) - } - // MARK: - Trojan /// Parses a Clash `type: trojan` node into a `.trojan(password:tls:)` diff --git a/Shared/ViewModels/VPNViewModel.swift b/Shared/ViewModels/VPNViewModel.swift index 4e0e4de..d5b7230 100644 --- a/Shared/ViewModels/VPNViewModel.swift +++ b/Shared/ViewModels/VPNViewModel.swift @@ -817,9 +817,8 @@ class VPNViewModel: ObservableObject { configurationDict["hysteriaUploadMbps"] = uploadMbps configurationDict["hysteriaDownloadMbps"] = downloadMbps configurationDict["hysteriaSNI"] = sni - case .nowhere(let key, let uploadMbps): + case .nowhere(let key): configurationDict["nowhereKey"] = key - configurationDict["nowhereUploadMbps"] = uploadMbps case .trojan(let password, let tls): configurationDict["trojanPassword"] = password configurationDict["trojanSNI"] = tls.serverName