mirror of
https://github.com/by-sonic/tglock.git
synced 2026-11-05 15:21:55 +03:00
fix(media): use native MTProto for CDN203 and retain recent diagnostics
This commit is contained in:
+2
-3
@@ -48,9 +48,8 @@ struct StatusSnapshot {
|
||||
route_failures: u32,
|
||||
/// Запросы, отклонённые политикой «в LAN-режиме только Telegram».
|
||||
///
|
||||
/// Ноль при неработающем телефоне означает, что он вообще не дотянулся до
|
||||
/// этой машины; не ноль — что дотянулся, и разбираться надо с адресами
|
||||
/// (by-sonic/tglock#42).
|
||||
/// Ноль означает, что фильтр не отклонял запросы. Доходит ли телефон до
|
||||
/// машины, показывают соединения и журнал, а не этот счётчик (#42).
|
||||
blocked: u32,
|
||||
/// Клиенты, которые дошли, но не сумели договориться. Почти всегда это
|
||||
/// ссылка `tg://proxy` от прошлого запуска, то есть другой секрет.
|
||||
|
||||
+232
-18
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user