Skip to content

Commit a8e51df

Browse files
committed
Censorship bypass: FakeTLS frag, WS fronts, MTProxy mirrors, WEB catalog, keepalive
1. FakeTLS ClientHello TCP fragmentation (MTTcpConnection.m) - Split ClientHello into 5-byte record header + body with 30ms delay - DPI boxes waiting for complete record miss SNI/ECH in first segment - HMAC already over full record; fragments concatenate correctly 2. MTProxy mirror list (ManagedAutomaticMtProxy.swift) - jsDelivr + gitmirror CDN as fallback before GitHub URLs - DoH TXT resolver (Google / Cloudflare / Mozilla) for proxy lists - Hardcoded seed placeholder for last-resort bootstrap 3. WS CF Worker front support (WebSocketDatacenterMapping.swift, MTWebSocketConnectionInterface.swift) - WebSocketFrontTemplate / WebSocketEndpointPlanConfig / planner fronts-first - HTTP Host header and TLS SNI use front's own hostname - sec_protocol_options_set_tls_server_name wires SNI override for fronts - Updated tests: httpHost/tlsServerName fields + front candidate test 4. WEB proxy catalog (WebProxyCatalog.swift) - Bootstrap hardcoded entries (operator fills) + HTTPS directory fetch - 'Choose WEB Proxy' sheet in ProxyListSettingsController with entries + manual option - ForkWebProxyStrings.catalogManual/catalogPick/catalogTitle strings 5. WEB proxy background keepalive (WebProxyManager, WebProxySidecar, AppDelegate) - sendKeepalivePing() on sidecar via PING frame stream 0 - UIBackgroundTask 'web-proxy-keepalive' sends PING 2s after entering background - secondsSinceLastActivity() tracks uplink/downlink frame timestamps - applicationDidBecomeActive skips carrier rebuild if activity <45s ago Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
1 parent 8f942b1 commit a8e51df

12 files changed

Lines changed: 632 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66

77
## [Unreleased]
88

9+
## [v12.9.2-3946-pre]
10+
11+
### Added
12+
- **FakeTLS TCP fragmentation:** ClientHello split into 5-byte TLS record header + body (30 ms delay) — DPI boxes waiting for a complete record no longer see SNI/ECH in the first segment.
13+
- **MTProxy mirrors:** jsDelivr + gitmirror CDN fallbacks before GitHub; DoH TXT resolver (Google/Cloudflare/Mozilla) for proxy lists; hardcoded seed placeholder.
14+
- **WS front / CF Worker support:** `WebSocketFrontTemplate`, `WebSocketEndpointPlanConfig`, `WebSocketEndpointPlanner.candidates(config:)` — planner now accepts operator-provided CF Worker front hostnames; HTTP `Host` header and TLS SNI use the front's own domain while cert validation stays on.
15+
- **WEB proxy catalog:** `WebProxyCatalog` with bootstrap + HTTPS directory fetch; "Choose WEB Proxy" sheet with catalog entries + "Enter manually…" fallback.
16+
- **WEB proxy background keepalive:** session PING sent from a `UIBackgroundTask` on entering background; active carrier with recent activity skips full rebuild on foreground resume.
17+
918
## [v12.9.2-3945-pre] — 2026-09-03
1019

1120
Pre-release: WEB hang/path recovery + Messages chrome nits.

‎submodules/MTWebSocketTransport/Sources/WebSocketDatacenterMapping.swift‎

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,158 @@ public enum WebSocketDatacenter {
2424
public static func path(isTestingEnvironment: Bool) -> String {
2525
return isTestingEnvironment ? WebSocketDatacenter.testPath : WebSocketDatacenter.productionPath
2626
}
27+
28+
public static func foldedDatacenterId(_ datacenterId: Int) -> Int {
29+
var dc = abs(datacenterId)
30+
if dc == 203 {
31+
dc = 2
32+
}
33+
return dc
34+
}
35+
}
36+
37+
/// Alternate front (CF Worker / custom origin) that terminates TLS on its own hostname and proxies
38+
/// WebSocket bytes to `kws{N}.web.telegram.org`. SNI on the phone is the front host — not Telegram.
39+
public struct WebSocketFrontTemplate: Equatable, Codable {
40+
/// Connect host. May contain `"{dc}"`. Example: `"front.example.com"` or `"kws{dc}.front.example.com"`.
41+
public var host: String
42+
public var port: UInt16
43+
/// Path. May contain `"{dc}"`. Example: `"/dc/{dc}/apiws"`. Trailing `/apiws` becomes `/apiws_test` in test env.
44+
public var pathTemplate: String
45+
public var httpHostTemplate: String?
46+
public var tlsServerNameTemplate: String?
47+
48+
public init(host: String, port: UInt16 = 443, pathTemplate: String, httpHostTemplate: String? = nil, tlsServerNameTemplate: String? = nil) {
49+
self.host = host
50+
self.port = port
51+
self.pathTemplate = pathTemplate
52+
self.httpHostTemplate = httpHostTemplate
53+
self.tlsServerNameTemplate = tlsServerNameTemplate
54+
}
55+
}
56+
57+
public enum WebSocketEndpointKind: Equatable {
58+
case telegramGateway
59+
case front
2760
}
2861

2962
public struct WebSocketEndpointCandidate: Equatable {
3063
public let host: String
3164
public let port: UInt16
3265
public let path: String
66+
public let httpHost: String
67+
public let tlsServerName: String
68+
public let kind: WebSocketEndpointKind
3369

34-
public init(host: String, port: UInt16 = 443, path: String) {
70+
public init(host: String, port: UInt16 = 443, path: String, httpHost: String? = nil, tlsServerName: String? = nil, kind: WebSocketEndpointKind = .telegramGateway) {
3571
self.host = host
3672
self.port = port
3773
self.path = path
74+
self.httpHost = httpHost ?? host
75+
self.tlsServerName = tlsServerName ?? host
76+
self.kind = kind
3877
}
3978
}
4079

80+
public struct WebSocketEndpointPlanConfig: Equatable {
81+
public enum Order: Equatable {
82+
case frontsFirst
83+
case telegramFirst
84+
}
85+
86+
public var fronts: [WebSocketFrontTemplate]
87+
public var order: Order
88+
89+
public init(fronts: [WebSocketFrontTemplate] = [], order: Order = .frontsFirst) {
90+
self.fronts = fronts
91+
self.order = order
92+
}
93+
94+
public static let telegramOnly = WebSocketEndpointPlanConfig(fronts: [], order: .telegramFirst)
95+
}
96+
97+
/// Hardcoded bootstrap fronts + directory URLs. Operator fills real Worker hostnames before shipping;
98+
/// empty templates keep the planner on Telegram gateways only.
99+
public enum WebSocketFrontBootstrap {
100+
public static let templates: [WebSocketFrontTemplate] = [
101+
// WebSocketFrontTemplate(host: "front.example.com", pathTemplate: "/dc/{dc}/apiws"),
102+
]
103+
104+
public static let directoryURLs: [String] = [
105+
// "https://front.example.com/fronts.json",
106+
]
107+
}
108+
41109
/// Combines `WebSocketDatacenter`'s hostname ordering and path selection into the ordered candidate
42-
/// list a connection attempt should walk through.
110+
/// list a connection attempt should walk through. Optional fronts (CF Worker) are tried first by default.
43111
public enum WebSocketEndpointPlanner {
44-
public static func candidates(datacenterId: Int, isMedia: Bool, isTestingEnvironment: Bool) -> [WebSocketEndpointCandidate] {
112+
public static func candidates(datacenterId: Int, isMedia: Bool, isTestingEnvironment: Bool, config: WebSocketEndpointPlanConfig = .telegramOnly) -> [WebSocketEndpointCandidate] {
113+
let telegram = telegramCandidates(datacenterId: datacenterId, isMedia: isMedia, isTestingEnvironment: isTestingEnvironment)
114+
let fronts = frontCandidates(datacenterId: datacenterId, isTestingEnvironment: isTestingEnvironment, templates: config.fronts)
115+
let combined: [WebSocketEndpointCandidate]
116+
switch config.order {
117+
case .frontsFirst:
118+
combined = fronts + telegram
119+
case .telegramFirst:
120+
combined = telegram + fronts
121+
}
122+
return dedupe(combined, limit: 8)
123+
}
124+
125+
private static func telegramCandidates(datacenterId: Int, isMedia: Bool, isTestingEnvironment: Bool) -> [WebSocketEndpointCandidate] {
45126
let path = WebSocketDatacenter.path(isTestingEnvironment: isTestingEnvironment)
46127
return WebSocketDatacenter.hostnames(datacenterId: datacenterId, isMedia: isMedia).map {
47-
WebSocketEndpointCandidate(host: $0, path: path)
128+
WebSocketEndpointCandidate(host: $0, path: path, kind: .telegramGateway)
129+
}
130+
}
131+
132+
private static func frontCandidates(datacenterId: Int, isTestingEnvironment: Bool, templates: [WebSocketFrontTemplate]) -> [WebSocketEndpointCandidate] {
133+
let dc = WebSocketDatacenter.foldedDatacenterId(datacenterId)
134+
var result: [WebSocketEndpointCandidate] = []
135+
for template in templates {
136+
let host = expand(template.host, dc: dc)
137+
guard !host.isEmpty else {
138+
continue
139+
}
140+
var path = expand(template.pathTemplate, dc: dc)
141+
if path.isEmpty {
142+
path = WebSocketDatacenter.path(isTestingEnvironment: isTestingEnvironment)
143+
} else if isTestingEnvironment {
144+
path = path.replacingOccurrences(of: "/apiws_test", with: "/apiws")
145+
if path.hasSuffix("/apiws") {
146+
path = String(path.dropLast("/apiws".count)) + WebSocketDatacenter.testPath
147+
}
148+
}
149+
let httpHost = expand(template.httpHostTemplate ?? template.host, dc: dc)
150+
let tlsName = expand(template.tlsServerNameTemplate ?? template.host, dc: dc)
151+
result.append(WebSocketEndpointCandidate(
152+
host: host,
153+
port: template.port == 0 ? 443 : template.port,
154+
path: path,
155+
httpHost: httpHost.isEmpty ? host : httpHost,
156+
tlsServerName: tlsName.isEmpty ? host : tlsName,
157+
kind: .front
158+
))
159+
}
160+
return result
161+
}
162+
163+
private static func expand(_ template: String, dc: Int) -> String {
164+
return template.replacingOccurrences(of: "{dc}", with: "\(dc)")
165+
}
166+
167+
private static func dedupe(_ candidates: [WebSocketEndpointCandidate], limit: Int) -> [WebSocketEndpointCandidate] {
168+
var seen = Set<String>()
169+
var out: [WebSocketEndpointCandidate] = []
170+
for candidate in candidates {
171+
let key = "\(candidate.host)|\(candidate.port)|\(candidate.path)"
172+
if seen.insert(key).inserted {
173+
out.append(candidate)
174+
if out.count >= limit {
175+
break
176+
}
177+
}
48178
}
179+
return out
49180
}
50181
}

‎submodules/MTWebSocketTransport/Tests/WebSocketDatacenterMappingTests.swift‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,42 @@ final class WebSocketDatacenterMappingTests: XCTestCase {
3838
func testEndpointPlannerCombinesHostsAndPath() {
3939
let candidates = WebSocketEndpointPlanner.candidates(datacenterId: 4, isMedia: true, isTestingEnvironment: true)
4040
XCTAssertEqual(candidates, [
41-
WebSocketEndpointCandidate(host: "kws4-1.web.telegram.org", path: "/apiws_test"),
42-
WebSocketEndpointCandidate(host: "kws4.web.telegram.org", path: "/apiws_test"),
41+
WebSocketEndpointCandidate(host: "kws4-1.web.telegram.org", path: "/apiws_test", kind: .telegramGateway),
42+
WebSocketEndpointCandidate(host: "kws4.web.telegram.org", path: "/apiws_test", kind: .telegramGateway),
4343
])
4444
}
4545

4646
func testEndpointPlannerDefaultPortIs443() {
4747
let candidates = WebSocketEndpointPlanner.candidates(datacenterId: 1, isMedia: false, isTestingEnvironment: false)
4848
XCTAssertEqual(candidates.map { $0.port }, [443, 443])
4949
}
50+
51+
func testEndpointPlannerTelegramCandidatesHaveIdenticalHttpHostAndTlsName() {
52+
let candidates = WebSocketEndpointPlanner.candidates(datacenterId: 2, isMedia: false, isTestingEnvironment: false)
53+
for c in candidates {
54+
XCTAssertEqual(c.httpHost, c.host)
55+
XCTAssertEqual(c.tlsServerName, c.host)
56+
XCTAssertEqual(c.kind, .telegramGateway)
57+
}
58+
}
59+
60+
func testEndpointPlannerFrontCandidateOverridesHttpHostAndSNI() {
61+
let template = WebSocketFrontTemplate(
62+
host: "worker.example.com",
63+
port: 443,
64+
pathTemplate: "/dc/{dc}/apiws",
65+
httpHostTemplate: "worker.example.com",
66+
tlsServerNameTemplate: "worker.example.com"
67+
)
68+
let config = WebSocketEndpointPlanConfig(fronts: [template], order: .frontsFirst)
69+
let candidates = WebSocketEndpointPlanner.candidates(datacenterId: 1, isMedia: false, isTestingEnvironment: false, config: config)
70+
guard let front = candidates.first else {
71+
return XCTFail("expected front candidate first")
72+
}
73+
XCTAssertEqual(front.kind, .front)
74+
XCTAssertEqual(front.httpHost, "worker.example.com")
75+
XCTAssertEqual(front.tlsServerName, "worker.example.com")
76+
// Remaining candidates are Telegram gateways
77+
XCTAssertTrue(candidates.dropFirst().allSatisfy { $0.kind == .telegramGateway })
78+
}
5079
}

‎submodules/MtProtoKit/Sources/MTTcpConnection.m‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1057,7 +1057,26 @@ - (void)start
10571057
_helloRandom = [[NSData alloc] initWithBytes:cHMAC length:CC_SHA256_DIGEST_LENGTH];
10581058
memcpy(((uint8_t *)helloData.mutableBytes) + 11, cHMAC, CC_SHA256_DIGEST_LENGTH);
10591059

1060-
[strongSelf->_socket writeData:helloData];
1060+
// TCP-split the FakeTLS ClientHello so a DPI midbox that waits for a
1061+
// complete record does not see SNI/ECH in the first segment. HMAC was
1062+
// already taken over the full record; fragments must concatenate to
1063+
// those exact bytes (never emit a second TLS record header).
1064+
static const NSUInteger kFakeTlsHelloFirstFragment = 5;
1065+
static const NSTimeInterval kFakeTlsHelloFragmentDelay = 0.03;
1066+
if (helloData.length > kFakeTlsHelloFirstFragment) {
1067+
NSData *part0 = [helloData subdataWithRange:NSMakeRange(0, kFakeTlsHelloFirstFragment)];
1068+
NSData *part1 = [helloData subdataWithRange:NSMakeRange(kFakeTlsHelloFirstFragment, helloData.length - kFakeTlsHelloFirstFragment)];
1069+
[strongSelf->_socket writeData:part0];
1070+
[[MTTcpConnection tcpQueue] dispatchAfter:kFakeTlsHelloFragmentDelay block:^{
1071+
__strong MTTcpConnection *innerSelf = strongSelf;
1072+
if (innerSelf == nil || innerSelf->_closed) {
1073+
return;
1074+
}
1075+
[innerSelf->_socket writeData:part1];
1076+
}];
1077+
} else {
1078+
[strongSelf->_socket writeData:helloData];
1079+
}
10611080
[strongSelf->_socket readDataToLength:5 withTimeout:-1 tag:MTTcpSocksReceiveHelloResponse];
10621081
} else {
10631082
strongSelf->_readyToSendData = true;

‎submodules/SettingsUI/Sources/Data and Storage/ProxyListSettingsController.swift‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import PresentationDataUtils
1111
import AccountContext
1212
import ShareController
1313
import UrlEscaping
14+
import WebProxyTransport
1415

1516
private final class ProxySettingsControllerArguments {
1617
let toggleEnabled: (Bool) -> Void
@@ -850,9 +851,53 @@ public func proxySettingsController(accountManager: AccountManager<TelegramAccou
850851
actionSheet?.dismissAnimated()
851852
addServer(.mtp)
852853
}),
853-
ActionSheetButtonItem(title: ForkWebProxyStrings.proxyType, color: .accent, action: { [weak actionSheet] in
854+
ActionSheetButtonItem(title: ForkWebProxyStrings.proxyType, color: .accent, action: { [weak actionSheet, weak strongController] in
854855
actionSheet?.dismissAnimated()
855-
addServer(.web)
856+
// If there are catalog entries, offer a pick sheet; otherwise go straight to manual.
857+
let bootstrap = WebProxyCatalog.bootstrapEntries
858+
if bootstrap.isEmpty {
859+
addServer(.web)
860+
return
861+
}
862+
// Offer catalog + manual option.
863+
WebProxyCatalog.load { entries in
864+
guard let controller = strongController else { return }
865+
let sheet = ActionSheetController(presentationData: presentationData)
866+
var items: [ActionSheetItem] = []
867+
for entry in entries {
868+
items.append(ActionSheetButtonItem(title: entry.title, color: .accent, action: { [weak sheet] in
869+
sheet?.dismissAnimated()
870+
guard let parsedSecret = MTProxySecret.parse(entry.secretHex),
871+
isSupportedWebProxySecret(parsedSecret.serialize()) else {
872+
addServer(.web)
873+
return
874+
}
875+
let server = ProxyServerSettings(host: entry.host, port: 443, connection: .web(secret: parsedSecret.serialize()))
876+
let _ = updateProxySettingsInteractively(accountManager: accountManager, { current in
877+
var current = current
878+
if !current.servers.contains(server) {
879+
current.servers.insert(server, at: 0)
880+
}
881+
current.activeServer = server
882+
current.enabled = true
883+
return current
884+
}).start()
885+
}))
886+
}
887+
items.append(ActionSheetButtonItem(title: ForkWebProxyStrings.catalogManual, color: .accent, action: { [weak sheet] in
888+
sheet?.dismissAnimated()
889+
addServer(.web)
890+
}))
891+
sheet.setItemGroups([
892+
ActionSheetItemGroup(items: items),
893+
ActionSheetItemGroup(items: [
894+
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak sheet] in
895+
sheet?.dismissAnimated()
896+
})
897+
])
898+
])
899+
controller.present(sheet, in: .window(.root))
900+
}
856901
})
857902
]), ActionSheetItemGroup(items: [
858903
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in

‎submodules/TelegramCore/Sources/Network/MTWebSocketConnectionInterface.swift‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ final class MTWebSocketConnectionInterface: NSObject, MTTcpConnectionInterface {
209209
private var endpointSelector: WebSocketEndpointSelector
210210
private var currentCandidateHost: String?
211211
private var currentCandidatePath: String?
212+
private var currentCandidateHttpHost: String?
213+
private var currentCandidateTlsServerName: String?
212214

213215
private var handshakeState: HandshakeState = .tcpConnecting
214216
private var handshakeResponseBuffer = Data()
@@ -344,6 +346,8 @@ final class MTWebSocketConnectionInterface: NSObject, MTTcpConnectionInterface {
344346
self.readBufferOffset = 0
345347
self.currentCandidateHost = candidate.host
346348
self.currentCandidatePath = candidate.path
349+
self.currentCandidateHttpHost = candidate.httpHost
350+
self.currentCandidateTlsServerName = candidate.tlsServerName
347351

348352
let host = NWEndpoint.Host(candidate.host)
349353
guard let port = NWEndpoint.Port(rawValue: candidate.port) else {
@@ -359,11 +363,15 @@ final class MTWebSocketConnectionInterface: NSObject, MTTcpConnectionInterface {
359363
tcpOptions.keepaliveInterval = 5
360364
tcpOptions.enableFastOpen = false
361365

362-
// Default sec_protocol_options: full certificate-chain + hostname validation, with SNI
363-
// derived by Network.framework from `host`. Deliberately NOT weakened — see the "TLS /
364-
// security" section of docs/websocket-transport.md for why tg-ws-proxy's CERT_NONE /
365-
// check_hostname=False pattern was not ported.
366+
// TLS SNI. For Telegram-gateway candidates, SNI is derived from `host` automatically.
367+
// For CF-Worker / custom-front candidates, `tlsServerName` may differ from `host`
368+
// (the Worker's own hostname) — use sec_protocol_options to override it while keeping
369+
// full certificate-chain validation against that Worker cert (NOT weakened).
366370
let tlsOptions = NWProtocolTLS.Options()
371+
let effectiveSNI = self.currentCandidateTlsServerName ?? candidate.host
372+
if effectiveSNI != candidate.host {
373+
sec_protocol_options_set_tls_server_name(tlsOptions.securityProtocolOptions, effectiveSNI)
374+
}
367375

368376
let parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions)
369377
let connection = NWConnection(host: host, port: port, using: parameters)
@@ -449,10 +457,13 @@ final class MTWebSocketConnectionInterface: NSObject, MTTcpConnectionInterface {
449457
guard let connection = self.connection, let host = self.currentCandidateHost, let path = self.currentCandidatePath else {
450458
return
451459
}
460+
// HTTP Host header: for CF-Worker fronts this is the Worker domain (same as httpHost),
461+
// not the Telegram gateway. For plain Telegram candidates they are identical.
462+
let httpHost = self.currentCandidateHttpHost ?? host
452463

453464
let key = Impl.makeSecWebSocketKey()
454465
var request = "GET \(path) HTTP/1.1\r\n"
455-
request += "Host: \(host)\r\n"
466+
request += "Host: \(httpHost)\r\n"
456467
request += "Upgrade: websocket\r\n"
457468
request += "Connection: Upgrade\r\n"
458469
request += "Sec-WebSocket-Key: \(key)\r\n"

0 commit comments

Comments
 (0)