fix(media): use native MTProto for CDN203 and retain recent diagnostics

This commit is contained in:
babin
2026-09-19 15:30:13 +03:00
parent b7e405eacb
commit 08afcdb7cb
9 changed files with 686 additions and 43 deletions
+2 -3
View File
@@ -48,9 +48,8 @@ struct StatusSnapshot {
route_failures: u32,
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
///
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
/// (by-sonic/tglock#42).
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
blocked: u32,
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
+232 -18
View File
@@ -1,5 +1,5 @@
use crate::config::ListenConfig;
use std::collections::{HashSet, VecDeque};
use std::collections::VecDeque;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
@@ -71,15 +71,17 @@ pub struct Stats {
shutdown: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
}
/// Однократные сообщения о том, что происходит с подключениями.
/// Сообщения о подключениях со склейкой недавних повторов.
///
/// Однократные намеренно: отклонённый адрес повторяется десятки раз в минуту,
/// Склейка намеренна: отклонённый адрес повторяется десятки раз в минуту,
/// и без склейки журнал превратился бы в одну строку, повторённую сто раз.
/// Число повторов при этом не теряется — оно в счётчике `blocked`.
#[derive(Default)]
struct Events {
pending: VecDeque<String>,
seen: HashSet<String>,
// FIFO of recent distinct messages, independent from the consumer queue.
// Once full, evict the oldest entry rather than silencing all future logs.
seen: VecDeque<String>,
}
/// Сколько разных событий помним, чтобы буфер не рос без границы.
@@ -132,13 +134,20 @@ impl Stats {
})
}
/// Запомнить событие, если такого ещё не было.
/// Запомнить событие, если его нет среди последних EVENT_LIMIT разных сообщений.
pub fn note(&self, message: impl Into<String>) {
let message = message.into();
let mut events = self.events.lock().unwrap();
if events.seen.len() >= EVENT_LIMIT || !events.seen.insert(message.clone()) {
if events.seen.contains(&message) {
return;
}
if events.seen.len() == EVENT_LIMIT {
events.seen.pop_front();
}
events.seen.push_back(message.clone());
if events.pending.len() == EVENT_LIMIT {
events.pending.pop_front();
}
events.pending.push_back(message);
}
@@ -683,13 +692,9 @@ fn dc_from_init(init: &[u8; 64]) -> Option<(u16, bool)> {
// -- WebSocket tunnel -------------------------------------------------------
/// Keeps `Stats::ws` equal to the number of *established* tunnels.
///
/// Counting attempts instead would let the interface announce «Telegram на
/// связи» while the WebSocket handshake is still failing over between routes,
/// which takes seconds per route. Reporting a working tunnel that does not
/// exist yet is the whole reason users saw «прокси подключён, а Telegram не
/// работает».
/// Counts opened upstream transports, not pending connection attempts.
/// WebSocket routes have completed their upgrade; native CDN routes have an
/// open TCP socket. Neither alone proves a successful Telegram protocol reply.
struct EstablishedTunnel<'a>(&'a Stats);
impl<'a> EstablishedTunnel<'a> {
@@ -715,7 +720,7 @@ async fn ws_tunnel(
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use futures_util::{SinkExt, StreamExt};
let (ws, connected) = match stats.transport.connect(dc, media).await {
let (connection, connected) = match stats.transport.connect(dc, media).await {
Ok(connected) => connected,
Err(error) => {
// Единственное место, где известно, ПОЧЕМУ туннеля нет. Раньше
@@ -729,6 +734,13 @@ async fn ws_tunnel(
let _tunnel = EstablishedTunnel::new(stats);
stats.note_tunnel(dc, connected.route.kind.ui_code());
let ws = match connection {
crate::transport::TelegramConnection::WebSocket(ws) => ws,
crate::transport::TelegramConnection::Tcp(remote) => {
return cdn_tcp_tunnel(tcp, remote, init, crypto, stats, &connected, dc).await;
}
};
let (mut tcp_r, mut tcp_w) = tokio::io::split(tcp);
let (mut ws_w, mut ws_r) = ws.split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
@@ -853,6 +865,79 @@ async fn ws_tunnel(
Ok(())
}
/// DC203's pinned CDN endpoint speaks obfuscated MTProto over TCP. Preserve the
/// same generated init and independent CTR streams used for a WebSocket route.
/// The transport engine restricts this connection to the exact CDN destination.
async fn cdn_tcp_tunnel(
client: TcpStream,
mut remote: TcpStream,
init: &[u8; 64],
crypto: Option<crate::mtproto::CryptoContext>,
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
dc: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
remote
.write_all(init)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
let (mut client_r, mut client_w) = client.into_split();
let (mut remote_r, mut remote_w) = remote.into_split();
let (upstream_crypto, downstream_crypto) = match crypto.map(|crypto| crypto.split()) {
Some((upstream, downstream)) => (Some(upstream), Some(downstream)),
None => (None, None),
};
let upstream = async {
let mut crypto = upstream_crypto;
let mut buffer = vec![0; 65536];
loop {
let read = client_r.read(&mut buffer).await?;
if read == 0 {
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(());
}
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
remote_w
.write_all(&buffer[..read])
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
}
};
let downstream = async {
let mut crypto = downstream_crypto;
let mut buffer = vec![0; 65536];
let mut received_payload = false;
loop {
let read = remote_r
.read(&mut buffer)
.await
.map_err(|error| tunnel_route_error(stats, connected, dc, &error.to_string()))?;
if read == 0 {
return if received_payload {
Ok(())
} else {
Err(tunnel_route_error(
stats,
connected,
dc,
"CDN TCP закрыт без ответа Telegram",
))
};
}
received_payload = true;
if let Some(crypto) = &mut crypto {
crypto.apply(&mut buffer[..read]);
}
client_w.write_all(&buffer[..read]).await?;
}
};
tokio::select! {
result = upstream => result,
result = downstream => result,
}
}
fn tunnel_route_error(
stats: &Stats,
connected: &crate::transport::ConnectedRoute,
@@ -1100,6 +1185,52 @@ mod tests {
);
}
#[test]
fn diagnostics_keep_new_events_after_the_first_sixty_four() {
let stats = Stats::with_secret([0; 16]);
for index in 0..=EVENT_LIMIT {
stats.note(format!("route failure {index}"));
}
{
let events = stats.events.lock().unwrap();
assert_eq!(events.seen.len(), EVENT_LIMIT);
assert_eq!(events.pending.len(), EVENT_LIMIT);
}
let events = stats.drain_events();
assert_eq!(events.first().unwrap(), "route failure 1");
assert_eq!(
events.last().unwrap(),
&format!("route failure {EVENT_LIMIT}")
);
// Draining must not discard deduplication history, but must not stop
// the next new error from reaching the operator either.
stats.note(format!("route failure {EVENT_LIMIT}"));
stats.note("a new upstream error");
assert_eq!(stats.drain_events(), vec!["a new upstream error"]);
}
#[test]
fn an_evicted_event_can_be_reported_again_without_repeating_recent_events() {
let stats = Stats::with_secret([0; 16]);
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
stats.note("original error");
assert!(stats.drain_events().is_empty());
// More distinct events than the retained history used to silence
// diagnostics permanently, even when the frontend drained every one.
for index in 0..EVENT_LIMIT * 2 {
let message = format!("error {index}");
stats.note(message.clone());
assert_eq!(stats.drain_events(), vec![message]);
}
stats.note("original error");
stats.note("original error");
assert_eq!(stats.drain_events(), vec!["original error"]);
assert_eq!(stats.events.lock().unwrap().seen.len(), EVENT_LIMIT);
}
/// Диагностика обязана показывать пару из одного соединения.
///
/// Пока это были два независимых поля, при десятках одновременных
@@ -1548,9 +1679,53 @@ mod tests {
}
}
#[allow(clippy::result_large_err)]
#[tokio::test]
async fn fragmented_encrypted_upload_and_download_preserve_both_streams() {
check_fragmented_encrypted_duplex(false).await;
}
#[tokio::test]
async fn cdn_tcp_preserves_fragmented_encrypted_duplex_and_clean_close() {
check_fragmented_encrypted_duplex(true).await;
}
#[tokio::test]
async fn cdn_tcp_closing_before_a_reply_is_reported_as_a_route_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut init = [0; INIT_LEN];
stream.read_exact(&mut init).await.unwrap();
});
let stats = Stats::new();
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
let (port, server) = start_proxy(stats.clone(), false).await;
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
client
.write_all(&unambiguous_client_init(&stats.secret, 203))
.await
.unwrap();
wait_until("CDN failure is counted", || {
stats.ws_failures.load(Ordering::Relaxed) == 1
})
.await;
assert_eq!(stats.transport.route_failures(), 1);
assert!(stats
.drain_events()
.iter()
.any(|event| event.contains("CDN TCP закрыт без ответа")));
relay.await.unwrap();
stats.stop();
server.await.unwrap().unwrap();
}
#[allow(clippy::result_large_err)]
async fn check_fragmented_encrypted_duplex(raw_tcp: bool) {
use futures_util::{SinkExt, StreamExt};
let request: Vec<u8> = (0..131_072).map(|i| (i % 251) as u8).collect();
let response: Vec<u8> = (0..262_144).map(|i| (i % 239) as u8).collect();
@@ -1559,7 +1734,30 @@ mod tests {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let relay_port = listener.local_addr().unwrap().port();
let relay = tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let (mut tcp, _) = listener.accept().await.unwrap();
if raw_tcp {
let mut init = [0; INIT_LEN];
tcp.read_exact(&mut init).await.unwrap();
let mut peer = crate::mtproto::test_relay_peer(&init);
let mut download = relay_response;
peer.encrypt(&mut download);
let (mut reader, mut writer) = tcp.into_split();
let send = async {
for chunk in download.chunks(8191) {
writer.write_all(chunk).await.unwrap();
tokio::task::yield_now().await;
}
};
let receive = async {
let mut received = vec![0; expected_request.len()];
reader.read_exact(&mut received).await.unwrap();
peer.decrypt(&mut received);
assert_eq!(received, expected_request);
};
tokio::join!(send, receive);
writer.shutdown().await.unwrap();
return;
}
let mut ws =
tokio_tungstenite::accept_hdr_async(tcp, |_: &Request, mut response: Response| {
response
@@ -1596,9 +1794,17 @@ mod tests {
sink.close().await.unwrap();
});
let stats = Stats::new();
stats.transport.force_local_route(relay_port);
if raw_tcp {
stats.transport.force_local_route_with(
relay_port,
crate::transport::RouteKind::TelegramTcp,
String::new(),
);
} else {
stats.transport.force_local_route(relay_port);
}
let (port, server) = start_proxy(stats.clone(), false).await;
let init = unambiguous_client_init(&stats.secret, -4);
let init = unambiguous_client_init(&stats.secret, if raw_tcp { 203 } else { -4 });
let mut peer = crate::mtproto::test_client_peer(&init, &stats.secret);
let mut client = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
// A mobile TCP stream can split the 64-byte init and subsequent data
@@ -1628,6 +1834,14 @@ mod tests {
})
.await
.expect("both encrypted directions must make progress");
assert_eq!(stats.ws_failures.load(Ordering::Relaxed), 0);
if raw_tcp {
assert_eq!(
stats.last_route(),
crate::transport::RouteKind::TelegramTcp.ui_code()
);
assert_eq!(stats.last_dc(), 203);
}
stats.stop();
server.await.unwrap().unwrap();
}
+172 -17
View File
@@ -15,15 +15,24 @@ const MAX_CONNECTING: usize = 3;
const FAILURE_BACKOFF_INITIAL: Duration = Duration::from_secs(30);
const FAILURE_BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
const HTTPS_PORT: u16 = 443;
const CDN203_IP: &str = "91.105.192.100";
pub type TelegramWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug)]
pub enum TelegramConnection {
WebSocket(Box<TelegramWebSocket>),
/// Native obfuscated2 transport, currently restricted to the CDN203 IP.
Tcp(TcpStream),
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RouteKind {
TelegramIp,
AlternateTelegramIp,
SystemDns,
CloudflareWorker,
TelegramTcp,
}
impl RouteKind {
@@ -33,6 +42,7 @@ impl RouteKind {
Self::AlternateTelegramIp => 2,
Self::SystemDns => 3,
Self::CloudflareWorker => 4,
Self::TelegramTcp => 5,
}
}
@@ -42,6 +52,7 @@ impl RouteKind {
2 => Some(Self::AlternateTelegramIp),
3 => Some(Self::SystemDns),
4 => Some(Self::CloudflareWorker),
5 => Some(Self::TelegramTcp),
_ => None,
}
}
@@ -53,6 +64,7 @@ impl RouteKind {
Self::AlternateTelegramIp => "Запасной Telegram IP",
Self::SystemDns => "Системный DNS",
Self::CloudflareWorker => "Cloudflare Worker",
Self::TelegramTcp => "Telegram CDN TCP",
}
}
}
@@ -73,11 +85,22 @@ pub struct Route {
pub kind: RouteKind,
/// TCP port to dial. Always 443 for Telegram and for Cloudflare Workers.
pub port: u16,
/// Wrap the connection in TLS. Always true outside tests.
/// TLS for WebSocket routes; false for native obfuscated2 CDN TCP.
pub secure: bool,
}
impl Route {
fn cdn_tcp() -> Self {
Self {
connect_host: CDN203_IP.to_owned(),
websocket_host: String::new(),
path: String::new(),
kind: RouteKind::TelegramTcp,
port: HTTPS_PORT,
secure: false,
}
}
/// A production route: TLS on 443.
fn https(connect_host: String, websocket_host: String, path: String, kind: RouteKind) -> Self {
Self {
@@ -193,7 +216,7 @@ impl TransportEngine {
&self,
dc: u16,
media: bool,
) -> Result<(TelegramWebSocket, ConnectedRoute), String> {
) -> Result<(TelegramConnection, ConnectedRoute), String> {
let key = DcKey { dc, media };
self.race_connections(key, |route| async move { connect_route(&route).await })
.await
@@ -242,7 +265,12 @@ impl TransportEngine {
}
Err(error) => {
self.record_failure(&route);
errors.push(format!("{} (TLS {}) — {}", route.connect_host, route.websocket_host, error));
let endpoint = if route.kind == RouteKind::TelegramTcp {
format!("{}:{} (MTProto TCP)", route.connect_host, route.port)
} else {
format!("{} (TLS {})", route.connect_host, route.websocket_host)
};
errors.push(format!("{endpoint} — {error}"));
}
}
}
@@ -305,6 +333,7 @@ impl TransportEngine {
candidates.sort_by_key(|route| {
let preferred_rank = u8::from(preferred != Some(route));
let kind_rank = match route.kind {
RouteKind::TelegramTcp => 0,
RouteKind::TelegramIp if Some(route) == all_routes.first() => 0,
// The operator explicitly configured this independent path.
// Start it after the primary IP, before more potentially
@@ -421,7 +450,7 @@ fn telegram_ips(dc: u16) -> &'static [&'static str] {
3 => &["149.154.175.100"],
4 => &["149.154.167.91", "149.154.167.220"],
5 => &["149.154.171.5"],
203 => &["91.105.192.100"],
203 => &[CDN203_IP],
_ => &[],
}
}
@@ -440,6 +469,13 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
};
let ips = telegram_ips(dc);
let mut routes = Vec::new();
if dc == 203 {
// CDN203 speaks native obfuscated2 on this exact IP:443. A WebSocket
// handshake on the same address can time out even while MTProto is
// healthy. Keep the destination unchanged and use its native wire
// transport; never redirect CDN authorization to ordinary DC2.
routes.push(Route::cdn_tcp());
}
for websocket_host in &websocket_hosts {
for (index, ip) in ips.iter().enumerate() {
@@ -469,7 +505,7 @@ pub fn routes_for_dc(dc: u16, media: bool) -> Vec<Route> {
routes
}
async fn connect_route(route: &Route) -> Result<TelegramWebSocket, String> {
async fn connect_route(route: &Route) -> Result<TelegramConnection, String> {
connect_route_with_config(route, tls_config()).await
}
@@ -499,7 +535,12 @@ fn tls_config() -> Arc<rustls::ClientConfig> {
async fn connect_route_with_config(
route: &Route,
config: Arc<rustls::ClientConfig>,
) -> Result<TelegramWebSocket, String> {
) -> Result<TelegramConnection, String> {
// Native TCP is a narrowly scoped CDN route, not a general proxy escape
// hatch. Reject malformed raw routes before dialing anything.
if route.kind == RouteKind::TelegramTcp && !allowed_tcp_route(route) {
return Err("native MTProto TCP разрешён только для закреплённого CDN203".to_owned());
}
let tcp = tokio::time::timeout(
CONNECT_TIMEOUT,
TcpStream::connect((route.connect_host.as_str(), route.port)),
@@ -510,6 +551,10 @@ async fn connect_route_with_config(
tcp.set_nodelay(true)
.map_err(|error| format!("TCP_NODELAY: {}", error))?;
if route.kind == RouteKind::TelegramTcp {
return Ok(TelegramConnection::Tcp(tcp));
}
let scheme = if route.secure { "wss" } else { "ws" };
let url = format!("{}://{}{}", scheme, route.websocket_host, route.path);
let mut request = url
@@ -524,15 +569,15 @@ async fn connect_route_with_config(
);
if !route.secure {
// Only reachable from tests, which run a local WebSocket server without
// a certificate. Production routes are always built by `Route::https`.
// Raw TCP has already returned above. Plain WebSocket is only used by
// local fixtures; production WebSocket routes are always HTTPS.
return tokio::time::timeout(
CONNECT_TIMEOUT,
tokio_tungstenite::client_async(request, MaybeTlsStream::Plain(tcp)),
)
.await
.map_err(|_| "таймаут WebSocket".to_owned())?
.map(|(websocket, _)| websocket)
.map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("рукопожатие WebSocket: {}", error));
}
@@ -547,10 +592,22 @@ async fn connect_route_with_config(
)
.await
.map_err(|_| "таймаут TLS/WebSocket".to_owned())?
.map(|(websocket, _)| websocket)
.map(|(websocket, _)| TelegramConnection::WebSocket(Box::new(websocket)))
.map_err(|error| format!("TLS/WebSocket handshake: {}", error))
}
fn allowed_tcp_route(route: &Route) -> bool {
#[cfg(test)]
if route.connect_host == "127.0.0.1" && !route.secure {
return true;
}
route.connect_host == CDN203_IP
&& route.port == HTTPS_PORT
&& route.websocket_host.is_empty()
&& route.path.is_empty()
&& !route.secure
}
fn valid_domain(domain: &str) -> bool {
if domain.is_empty() || domain.len() > 253 || !domain.contains('.') {
return false;
@@ -578,10 +635,13 @@ mod tests {
}
#[test]
fn dc203_uses_dc2_websocket_and_its_own_ip() {
fn dc203_prefers_native_tcp_and_never_changes_the_cdn_destination() {
let routes = routes_for_dc(203, false);
assert_eq!(routes[0].websocket_host, "kws2.web.telegram.org");
assert_eq!(routes[0].kind, RouteKind::TelegramTcp);
assert!(routes[0].websocket_host.is_empty());
assert!(!routes[0].secure);
assert_eq!(routes[0].connect_host, "91.105.192.100");
assert_eq!(routes[1].websocket_host, "kws2.web.telegram.org");
assert!(routes
.iter()
.all(|route| route.connect_host == "91.105.192.100"));
@@ -594,6 +654,12 @@ mod tests {
dc: 203,
media: true,
});
let ordered = engine.ordered_candidates(DcKey {
dc: 203,
media: true,
});
assert_eq!(ordered[0].kind, RouteKind::TelegramTcp);
assert_eq!(ordered[1].kind, RouteKind::CloudflareWorker);
assert!(routes
.iter()
.filter(|route| route.kind == RouteKind::CloudflareWorker)
@@ -648,7 +714,7 @@ mod tests {
}
#[test]
fn every_production_route_is_tls_on_443() {
fn production_websockets_use_tls_and_native_tcp_is_restricted_to_cdn203() {
let engine = TransportEngine::new();
engine.set_worker_domains(&["fallback.workers.dev".to_owned()]);
for dc in [1, 2, 3, 4, 5, 203] {
@@ -657,7 +723,13 @@ mod tests {
assert!(!routes.is_empty(), "DC{dc} must have at least one route");
for route in routes {
assert_eq!(route.port, 443, "{route:?}");
assert!(route.secure, "{route:?}");
if route.kind == RouteKind::TelegramTcp {
assert_eq!(dc, 203);
assert_eq!(route.connect_host, CDN203_IP);
assert!(allowed_tcp_route(&route));
} else {
assert!(route.secure, "{route:?}");
}
}
}
}
@@ -902,6 +974,7 @@ mod tests {
RouteKind::AlternateTelegramIp,
RouteKind::SystemDns,
RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] {
assert_eq!(RouteKind::from_ui_code(kind.ui_code()), Some(kind));
assert_eq!(route_label(kind.ui_code()), kind.label());
@@ -917,6 +990,7 @@ mod tests {
RouteKind::AlternateTelegramIp,
RouteKind::SystemDns,
RouteKind::CloudflareWorker,
RouteKind::TelegramTcp,
] {
assert_ne!(route_label(0), kind.label());
}
@@ -1131,14 +1205,95 @@ mod tests {
);
}
#[tokio::test]
async fn native_tcp_connects_without_sending_a_tls_or_websocket_handshake() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let engine = TransportEngine::new();
engine.force_local_route_with(
listener.local_addr().unwrap().port(),
RouteKind::TelegramTcp,
String::new(),
);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut bytes = [0; 4];
socket.read_exact(&mut bytes).await.unwrap();
assert_eq!(
&bytes, b"init",
"first bytes must be native transport bytes"
);
socket.write_all(b"pong").await.unwrap();
});
let (connection, connected) =
tokio::time::timeout(Duration::from_secs(1), engine.connect(203, true))
.await
.unwrap()
.unwrap();
assert_eq!(connected.route.kind, RouteKind::TelegramTcp);
let TelegramConnection::Tcp(mut socket) = connection else {
panic!("native CDN route must return a TCP stream");
};
assert!(socket.nodelay().unwrap());
socket.write_all(b"init").await.unwrap();
let mut reply = [0; 4];
tokio::time::timeout(Duration::from_secs(1), socket.read_exact(&mut reply))
.await
.unwrap()
.unwrap();
assert_eq!(&reply, b"pong");
server.await.unwrap();
}
#[tokio::test]
async fn raw_tcp_failure_is_named_and_enters_cooldown() {
let engine = TransportEngine::new();
// Port zero cannot accept a TCP connection; no external traffic.
engine.force_local_route_with(0, RouteKind::TelegramTcp, String::new());
let error = engine.connect(203, false).await.unwrap_err();
assert!(error.contains("MTProto TCP"), "{error}");
assert!(
!error.contains("TLS"),
"native transport must not be called TLS"
);
assert_eq!(engine.route_failures(), 1);
assert!(engine
.ordered_candidates(DcKey {
dc: 203,
media: false
})
.is_empty());
}
#[tokio::test]
async fn raw_tcp_guard_rejects_a_different_destination_before_dialing() {
let mut route = Route::cdn_tcp();
route.connect_host = "127.0.0.2".to_owned();
let error = connect_route(&route).await.unwrap_err();
assert!(error.contains("только для закреплённого CDN203"), "{error}");
for dc in [1, 2, 3, 4, 5, 999] {
assert!(routes_for_dc(dc, false)
.iter()
.all(|route| route.kind != RouteKind::TelegramTcp));
}
}
#[tokio::test]
#[ignore = "requires live Telegram network access"]
async fn connects_to_all_production_data_centers() {
let engine = TransportEngine::new();
for dc in [1, 2, 3, 4, 5, 203] {
let (mut websocket, connected) = engine.connect(dc, false).await.unwrap();
assert!(!connected.route.websocket_host.is_empty());
websocket.close(None).await.unwrap();
let (connection, connected) = engine.connect(dc, false).await.unwrap();
assert!(!connected.route.connect_host.is_empty());
match connection {
TelegramConnection::WebSocket(mut websocket) => {
websocket.close(None).await.unwrap()
}
TelegramConnection::Tcp(mut socket) => {
use tokio::io::AsyncWriteExt;
socket.shutdown().await.unwrap();
}
}
}
}
}