diff --git a/Anywhere/ContentView.swift b/Anywhere/ContentView.swift index 323b2fc..ab2afba 100644 --- a/Anywhere/ContentView.swift +++ b/Anywhere/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { @State private var showingDeepLinkAddSheet = false @State private var showingManualAddSheet = false @State private var pendingDeepLinkURL: String? + @State private var pendingDeepLinkHost: String? private var showOrphanedAlert: Binding { Binding( @@ -28,13 +29,15 @@ struct ContentView: View { if let url = newValue { selectedTab = .proxies pendingDeepLinkURL = url + pendingDeepLinkHost = deepLinkManager.host deepLinkManager.url = nil + deepLinkManager.host = nil showingDeepLinkAddSheet = true } } - .sheet(isPresented: $showingDeepLinkAddSheet, onDismiss: { pendingDeepLinkURL = nil }) { + .sheet(isPresented: $showingDeepLinkAddSheet, onDismiss: { pendingDeepLinkURL = nil; pendingDeepLinkHost = nil }) { DynamicSheet(animation: .snappy(duration: 0.3, extraBounce: 0)) { - AddProxyView(showingManualAddSheet: $showingManualAddSheet, deepLinkURL: pendingDeepLinkURL) + AddProxyView(showingManualAddSheet: $showingManualAddSheet, deepLinkURL: pendingDeepLinkURL, deepLinkHost: pendingDeepLinkHost) } } .sheet(isPresented: $showingManualAddSheet) { diff --git a/Anywhere/DeepLinkManager.swift b/Anywhere/DeepLinkManager.swift index c66de84..aa2545f 100644 --- a/Anywhere/DeepLinkManager.swift +++ b/Anywhere/DeepLinkManager.swift @@ -10,9 +10,11 @@ import Combine final class DeepLinkManager: ObservableObject { @Published var url: String? + var host: String? // Supported deep link schemes: // anywhere://add-proxy?link= + // anywhere://add-proxy?host=&link= // vless://<...> // ss://<...> // sudoku://<...> @@ -21,6 +23,7 @@ final class DeepLinkManager: ObservableObject { case "anywhere": handleAnywhereScheme(url) case "vless", "hysteria2", "hy2", "nowhere", "trojan", "anytls", "ss", "quic", "sudoku": + self.host = nil self.url = url.absoluteString default: break @@ -28,12 +31,8 @@ final class DeepLinkManager: ObservableObject { } private func handleAnywhereScheme(_ url: URL) { - guard url.host == "add-proxy" else { return } - // Take everything after "?link=" - let string = url.absoluteString - guard let range = string.range(of: "?link=") else { return } - let rawLink = String(string[range.upperBound...]) - guard !rawLink.isEmpty else { return } - self.url = rawLink.removingPercentEncoding ?? rawLink + guard url.host == "add-proxy", let parsed = AnywhereProxyLink.parse(url.absoluteString) else { return } + self.host = parsed.host + self.url = parsed.link } } diff --git a/Anywhere/Views/ProxyList/AddProxyView.swift b/Anywhere/Views/ProxyList/AddProxyView.swift index 6367bcc..9374297 100644 --- a/Anywhere/Views/ProxyList/AddProxyView.swift +++ b/Anywhere/Views/ProxyList/AddProxyView.swift @@ -52,13 +52,16 @@ struct AddProxyView: View { @State private var errorMessage = "" @State private var showingRemnawaveHWIDAlert = false @State private var pendingSubscriptionURL = "" + @State private var pendingSubscriptionHost: String? + @State private var deepLinkHost: String? - init(showingManualAddSheet: Binding, deepLinkURL: String? = nil) { + init(showingManualAddSheet: Binding, deepLinkURL: String? = nil, deepLinkHost: String? = nil) { _showingManualAddSheet = showingManualAddSheet if let deepLinkURL { _selectedMethod = State(initialValue: .link) _linkURL = State(initialValue: deepLinkURL) } + _deepLinkHost = State(initialValue: deepLinkHost) } var body: some View { @@ -96,7 +99,7 @@ struct AddProxyView: View { } .alert("Remnawave HWID", isPresented: $showingRemnawaveHWIDAlert) { Button("Enable") { - fetchSubscription(url: pendingSubscriptionURL, withRemnawaveHWID: true) + fetchSubscription(url: pendingSubscriptionURL, host: pendingSubscriptionHost, withRemnawaveHWID: true) } Button("Cancel", role: .cancel) {} } message: { @@ -249,7 +252,7 @@ struct AddProxyView: View { guard let clip = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines) else { return } // Accept any single-proxy URL the parser knows, plus `http://` for // subscription URLs (the only scheme that's never a single proxy). - if ProxyConfiguration.canParseURL(clip) || clip.hasPrefix("http://") { + if ProxyConfiguration.canParseURL(clip) || clip.hasPrefix("http://") || clip.hasPrefix("anywhere://add-proxy") { linkURL = clip } } @@ -271,6 +274,11 @@ struct AddProxyView: View { private func importFromString(_ string: String) { let trimmedURL = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let fronting = AnywhereProxyLink.parse(trimmedURL) { + importSubscription(url: fronting.link, host: fronting.host) + return + } + // `https://` is ambiguous — Naive HTTP proxy or subscription — so the // user's `httpsLinkType` choice overrides the parser's. let isHTTPSAsSubscription = trimmedURL.hasPrefix("https://") && httpsLinkType == .subscription @@ -289,22 +297,26 @@ struct AddProxyView: View { showingError = true } } else { - // Treat as subscription URL - let requiresRemnawaveHWID = SubscriptionDomainHelper.shouldRequireRemnawaveHWID(for: trimmedURL) - if requiresRemnawaveHWID && !AWCore.getRemnawaveHWIDEnabled() { - pendingSubscriptionURL = trimmedURL - showingRemnawaveHWIDAlert = true - return - } - fetchSubscription(url: trimmedURL, withRemnawaveHWID: requiresRemnawaveHWID) + importSubscription(url: trimmedURL, host: deepLinkHost) + } + } + + private func importSubscription(url: String, host: String?) { + let requiresRemnawaveHWID = SubscriptionDomainHelper.shouldRequireRemnawaveHWID(for: url) + if requiresRemnawaveHWID && !AWCore.getRemnawaveHWIDEnabled() { + pendingSubscriptionURL = url + pendingSubscriptionHost = host + showingRemnawaveHWIDAlert = true + return } + fetchSubscription(url: url, host: host, withRemnawaveHWID: requiresRemnawaveHWID) } - private func fetchSubscription(url: String, withRemnawaveHWID: Bool) { + private func fetchSubscription(url: String, host: String?, withRemnawaveHWID: Bool) { isLoading = true Task { do { - let result = try await SubscriptionFetcher.fetch(url: url, withRemnawaveHWID: withRemnawaveHWID) + let result = try await SubscriptionFetcher.fetch(url: url, host: host, withRemnawaveHWID: withRemnawaveHWID) let subscription = Subscription( name: result.name ?? URL(string: url)?.host ?? String(localized: "Subscription"), url: url, @@ -312,7 +324,8 @@ struct AddProxyView: View { upload: result.upload, download: result.download, total: result.total, - expire: result.expire + expire: result.expire, + frontHost: host ) viewModel.addSubscription(configurations: result.configurations, subscription: subscription) dismiss() diff --git a/README.md b/README.md index 9cb1a3c..ed61bcc 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,22 @@ anywhere://add-proxy?link= > **Note:** The `link` parameter is parsed by taking everything after `?link=` verbatim, so the inner URL does **not** need to be percent-encoded. For example, `anywhere://add-proxy?link=https://example.com/sub?token=abc&foo=bar` works as expected. +#### Domain Fronting + +To fetch a subscription through a CDN front domain (so SNI-based DPI cannot block it), add a `host` parameter before `link`: + +``` +anywhere://add-proxy?host=&link= +``` + +| Part | Role | +|------|------| +| `link` host | TLS **SNI** / front domain in the ClientHello (also the connect target) | +| `host` | real HTTP **`Host:`** header — the actual CDN origin | +| `link` path | sent as-is to the real origin | + +The request connects to the front domain (SNI set to it) and sends the path with `Host: `. When `host` is omitted, behavior is unchanged. The `link` value may be passed plainly or base64-encoded; if it base64-decodes to a supported URL, the decoded value is used. This link works both as a deep link and when pasted into the Add Proxy link field. + ### Proxy URI Schemes Tapping any of the following links on iOS will open Anywhere and pre-fill the full URI in the Add Proxy view for import: diff --git a/Shared/Models/Subscription.swift b/Shared/Models/Subscription.swift index 77c9b26..71332cd 100644 --- a/Shared/Models/Subscription.swift +++ b/Shared/Models/Subscription.swift @@ -18,8 +18,9 @@ struct Subscription: Identifiable, Codable { var expire: Date? var collapsed: Bool var isNameCustomized: Bool + var frontHost: String? - init(id: UUID = UUID(), name: String, url: String, lastUpdate: Date? = nil, upload: Int64? = nil, download: Int64? = nil, total: Int64? = nil, expire: Date? = nil, collapsed: Bool = false, isNameCustomized: Bool = false) { + init(id: UUID = UUID(), name: String, url: String, lastUpdate: Date? = nil, upload: Int64? = nil, download: Int64? = nil, total: Int64? = nil, expire: Date? = nil, collapsed: Bool = false, isNameCustomized: Bool = false, frontHost: String? = nil) { self.id = id self.name = name self.url = url @@ -30,6 +31,7 @@ struct Subscription: Identifiable, Codable { self.expire = expire self.collapsed = collapsed self.isNameCustomized = isNameCustomized + self.frontHost = frontHost } init(from decoder: Decoder) throws { @@ -44,5 +46,6 @@ struct Subscription: Identifiable, Codable { expire = try container.decodeIfPresent(Date.self, forKey: .expire) collapsed = (try? container.decode(Bool.self, forKey: .collapsed)) ?? false isNameCustomized = (try? container.decode(Bool.self, forKey: .isNameCustomized)) ?? false + frontHost = try container.decodeIfPresent(String.self, forKey: .frontHost) } } diff --git a/Shared/Utilities/AnywhereProxyLink.swift b/Shared/Utilities/AnywhereProxyLink.swift new file mode 100644 index 0000000..a0a281a --- /dev/null +++ b/Shared/Utilities/AnywhereProxyLink.swift @@ -0,0 +1,52 @@ +// +// AnywhereProxyLink.swift +// Anywhere +// +// Created by NodePassProject on 6/5/26. +// + +import Foundation + +struct AnywhereProxyLink { + let link: String + let host: String? + + static func parse(_ string: String) -> AnywhereProxyLink? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("anywhere://add-proxy") else { return nil } + + if let hostRange = trimmed.range(of: "?host="), + let linkRange = trimmed.range(of: "&link=") { + let rawHost = String(trimmed[hostRange.upperBound.. Result { + static func fetch(url urlString: String, host: String? = nil, withRemnawaveHWID: Bool = false) async throws -> Result { guard let url = URL(string: urlString) else { throw FetchError.invalidURL } @@ -44,9 +45,19 @@ struct SubscriptionFetcher { if withRemnawaveHWID { request.setValue(AWCore.getIdentifier(), forHTTPHeaderField: "x-hwid") } + if let host, !host.isEmpty { + request.setValue(host, forHTTPHeaderField: "Host") + } let allowInsecure = AWCore.getAllowInsecure() - let delegate: InsecureSessionDelegate? = allowInsecure ? InsecureSessionDelegate() : nil + let delegate: URLSessionDelegate? + if let host, !host.isEmpty, let frontDomain = url.host { + delegate = FrontingSessionDelegate(frontDomain: frontDomain, allowInsecure: allowInsecure) + } else if allowInsecure { + delegate = InsecureSessionDelegate() + } else { + delegate = nil + } let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession(configuration: .default, delegate: delegate, delegateQueue: nil).data(for: request) @@ -178,3 +189,31 @@ private final class InsecureSessionDelegate: NSObject, URLSessionDelegate { return (.performDefaultHandling, nil) } } + +// MARK: - URLSession delegate for domain fronting (validates trust against the front domain) + +private final class FrontingSessionDelegate: NSObject, URLSessionDelegate { + private let frontDomain: String + private let allowInsecure: Bool + + init(frontDomain: String, allowInsecure: Bool) { + self.frontDomain = frontDomain + self.allowInsecure = allowInsecure + } + + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + if allowInsecure { + return (.useCredential, URLCredential(trust: trust)) + } + let policy = SecPolicyCreateSSL(true, frontDomain as CFString) + SecTrustSetPolicies(trust, policy) + if SecTrustEvaluateWithError(trust, nil) { + return (.useCredential, URLCredential(trust: trust)) + } + return (.cancelAuthenticationChallenge, nil) + } +} diff --git a/Shared/ViewModels/VPNViewModel.swift b/Shared/ViewModels/VPNViewModel.swift index f695fc4..f446686 100644 --- a/Shared/ViewModels/VPNViewModel.swift +++ b/Shared/ViewModels/VPNViewModel.swift @@ -292,7 +292,7 @@ class VPNViewModel: ObservableObject { } func updateSubscription(_ subscription: Subscription) async throws { - let result = try await SubscriptionFetcher.fetch(url: subscription.url) + let result = try await SubscriptionFetcher.fetch(url: subscription.url, host: subscription.frontHost) // Check if selection pointed to a configuration in this subscription let selectedWasInSubscription = selectedConfiguration.flatMap { $0.subscriptionId == subscription.id } ?? false