Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Anywhere/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool> {
Binding(
Expand All @@ -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) {
Expand Down
13 changes: 6 additions & 7 deletions Anywhere/DeepLinkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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=<link>
// anywhere://add-proxy?host=<host>&link=<link>
// vless://<...>
// ss://<...>
// sudoku://<...>
Expand All @@ -21,19 +23,16 @@ 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
}
}

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
}
}
41 changes: 27 additions & 14 deletions Anywhere/Views/ProxyList/AddProxyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>, deepLinkURL: String? = nil) {
init(showingManualAddSheet: Binding<Bool>, 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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
Expand All @@ -289,30 +297,35 @@ 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,
lastUpdate: Date(),
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()
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ anywhere://add-proxy?link=<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=<realHost>&link=<frontedURL>
```

| 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: <realHost>`. 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:
Expand Down
5 changes: 4 additions & 1 deletion Shared/Models/Subscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
}
52 changes: 52 additions & 0 deletions Shared/Utilities/AnywhereProxyLink.swift
Original file line number Diff line number Diff line change
@@ -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..<linkRange.lowerBound])
let rawLink = String(trimmed[linkRange.upperBound...])
guard !rawLink.isEmpty else { return nil }
let decodedHost = rawHost.removingPercentEncoding ?? rawHost
return AnywhereProxyLink(
link: resolveLink(rawLink.removingPercentEncoding ?? rawLink),
host: decodedHost.isEmpty ? nil : decodedHost
)
}

guard let linkRange = trimmed.range(of: "?link=") else { return nil }
let rawLink = String(trimmed[linkRange.upperBound...])
guard !rawLink.isEmpty else { return nil }
return AnywhereProxyLink(
link: resolveLink(rawLink.removingPercentEncoding ?? rawLink),
host: nil
)
}

// MARK: - Base64 link resolution

private static func resolveLink(_ link: String) -> String {
guard let data = Data(base64Encoded: link, options: .ignoreUnknownCharacters),
let decoded = String(data: data, encoding: .utf8) else {
return link
}
let trimmed = decoded.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("http://") || ProxyConfiguration.parsableURLPrefixes.contains(where: { trimmed.hasPrefix($0) }) {
return trimmed
}
return link
}
}
43 changes: 41 additions & 2 deletions Shared/Utilities/SubscriptionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import Security

struct SubscriptionFetcher {
struct Result {
Expand Down Expand Up @@ -34,7 +35,7 @@ struct SubscriptionFetcher {
}
}

static func fetch(url urlString: String, withRemnawaveHWID: Bool = false) async throws -> 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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion Shared/ViewModels/VPNViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down