All checks were successful
Desktop source guards / guards (push) Successful in 6s
Подавление жило только в памяти, и каждый запуск снова проверял заблокированный kws1 бюджетами 1+2+4+8 с, прежде чем уйти в туннель. Оно хранится в tdata/wss_route_health; после перезапуска восстанавливается не больше 10 минут, на случай смены сети. Как в Android cad29e949.
965 lines
30 KiB
C++
965 lines
30 KiB
C++
/*
|
||
This file is part of Telegram Desktop,
|
||
the official desktop application for the Telegram messaging service.
|
||
|
||
For license and copyright information please follow this link:
|
||
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||
*/
|
||
#include "mtproto/proxy/wss/socket.h"
|
||
|
||
#include "mtproto/protocol/mtproto_binary.h"
|
||
#include "mtproto/proxy/diagnostics.h"
|
||
#include "logs.h"
|
||
#include "base/bytes.h"
|
||
#include "base/invoke_queued.h"
|
||
|
||
#include <crl/crl_time.h>
|
||
|
||
#include <cstring>
|
||
#include <algorithm>
|
||
#include <map>
|
||
|
||
#include <QtCore/QCryptographicHash>
|
||
#include <QtCore/QMutex>
|
||
#include <QtCore/QDateTime>
|
||
#include <QtCore/QFile>
|
||
|
||
namespace MTP::details {
|
||
namespace {
|
||
|
||
constexpr auto kWssMaxFrame = 2 * 1024 * 1024;
|
||
constexpr auto kWssHeaderLimit = 32 * 1024;
|
||
|
||
// How long to remember that only the fallback (domain) relay host works.
|
||
constexpr auto kRelayFallbackPreferenceTtl = 30 * 60 * crl::time(1000);
|
||
|
||
// Which relay host actually works is remembered across sockets: a blocked
|
||
// primary relay IP would otherwise be retried first by EVERY new socket,
|
||
// and the session-level connect watchdog (1s on the first attempt) kills
|
||
// the socket before errorOccurred fires, so the in-socket fallback never
|
||
// gets a chance and each reconnect repeats the dead-host dance.
|
||
struct RelayPreference {
|
||
bool preferFallback = false;
|
||
crl::time until = 0;
|
||
};
|
||
|
||
QMutex RelayPreferencesMutex;
|
||
std::map<QString, RelayPreference> RelayPreferences;
|
||
|
||
[[nodiscard]] QString RelayPreferenceKey(const WssRoute &route) {
|
||
return route.relayHost + u":"_q + QString::number(route.relayPort);
|
||
}
|
||
|
||
[[nodiscard]] bool HasRelayFallback(const WssRoute &route) {
|
||
return !route.relayHostFallback.isEmpty()
|
||
&& (route.relayHostFallback != route.relayHost);
|
||
}
|
||
|
||
[[nodiscard]] bool PreferRelayFallback(const WssRoute &route) {
|
||
if (!HasRelayFallback(route)) {
|
||
return false;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = RelayPreferences.find(RelayPreferenceKey(route));
|
||
return (i != end(RelayPreferences))
|
||
&& i->second.preferFallback
|
||
&& (i->second.until > crl::now());
|
||
}
|
||
|
||
constexpr auto kTunnelOnlyDcId = 203;
|
||
|
||
// Отдельный от выбора адреса учёт: у датацентра может не открываться ни один
|
||
// адрес релея — у части провайдеров порт 443 к нему закрыт целиком, по обоим
|
||
// протоколам. Держать такой датацентр в вечных попытках бессмысленно: медиа
|
||
// оттуда не загрузится никогда, хотя прямое соединение может работать. После
|
||
// нескольких неудач подряд, ни одна из которых не дошла даже до TCP, маршрут
|
||
// WSS для этого датацентра отключается, и фабрика сокетов создаёт обычный TCP.
|
||
constexpr auto kRouteFailuresBeforeSuppress = 3;
|
||
// Туннель медленнее релея, поэтому держать в нём основной датацентр дольше
|
||
// пары минут дороже, чем лишний раз проверить релей.
|
||
constexpr auto kRouteSuppressTtl = 2 * 60 * crl::time(1000);
|
||
// A throttled network freezes TCP to Cloudflare after about 16 KB downstream,
|
||
// TLS handshake included (Android logs (9) and (10), desktop log 25.09: no
|
||
// tunnel socket got more than ~13 KB of MTProto). A file connection is
|
||
// reopened after one 8 KB part, before it reaches that limit; a tunnel that
|
||
// delivered this much is working, even if it would freeze later.
|
||
constexpr auto kTunnelRotateBytes = qint64(4 * 1024);
|
||
// Chat sessions through the tunnel froze after 11-15 KB and waited out the
|
||
// 8 s receive timeout every 15-50 s; they are reopened after this much.
|
||
constexpr auto kTunnelMainRotateBytes = qint64(8 * 1024);
|
||
// An upgraded tunnel socket killed sooner than this without data was cut by
|
||
// a connect budget, not found silent.
|
||
constexpr auto kTunnelSilentAfter = 4 * crl::time(1000);
|
||
// Соединения открываются пачкой, и их таймауты приходят пачкой. Одна пачка —
|
||
// один провал, а не «три подряд».
|
||
constexpr auto kRouteFailureCoalesce = 2 * crl::time(1000);
|
||
// Провайдер глотает SYN отдельного потока, а соседний сокет к тому же адресу
|
||
// проходит. Пока адрес недавно принимал TCP, таймаут соединения — шум потока,
|
||
// а не недоступный релей.
|
||
constexpr auto kRecentTcpSuccess = 30 * crl::time(1000);
|
||
|
||
struct RouteHealth {
|
||
int consecutiveFailures = 0;
|
||
crl::time suppressedUntil = 0;
|
||
crl::time lastFailureAt = 0;
|
||
// Suppressions in a row without a single answer in between: a relay the
|
||
// network blocks outright was probed every two minutes, and each probe
|
||
// (1+2+4+8 s of connect budgets) was 15 s without DC1 (desktop log
|
||
// 25.09, 18:40 and 18:43). Each repeat doubles the suppression.
|
||
int suppressions = 0;
|
||
};
|
||
constexpr auto kRouteSuppressMaxTtl = 30 * 60 * crl::time(1000);
|
||
// Suppression lived only in memory, and every launch probed the blocked
|
||
// relay again before the tunnel. It is kept in tdata; what a restart
|
||
// restores is capped, since the network may have changed meanwhile.
|
||
constexpr auto kRouteSuppressRestoreMax = 10 * 60 * crl::time(1000);
|
||
|
||
std::map<QString, RouteHealth> RouteHealthByDomain;
|
||
bool RouteHealthLoaded = false;
|
||
|
||
[[nodiscard]] QString RouteHealthPath() {
|
||
return cWorkingDir() + u"tdata/wss_route_health"_q;
|
||
}
|
||
|
||
// Both called with RelayPreferencesMutex held.
|
||
void LoadRouteHealthLocked() {
|
||
if (RouteHealthLoaded) {
|
||
return;
|
||
}
|
||
RouteHealthLoaded = true;
|
||
auto file = QFile(RouteHealthPath());
|
||
if (!file.open(QIODevice::ReadOnly)) {
|
||
return;
|
||
}
|
||
const auto now = crl::now();
|
||
const auto wall = QDateTime::currentMSecsSinceEpoch();
|
||
for (const auto &line : QString::fromUtf8(file.readAll()).split('\n')) {
|
||
const auto parts = line.split(' ', Qt::SkipEmptyParts);
|
||
if (parts.size() != 3) {
|
||
continue;
|
||
}
|
||
const auto suppressions = parts[1].toInt();
|
||
const auto left = std::min(
|
||
parts[2].toLongLong() - wall,
|
||
qint64(kRouteSuppressRestoreMax));
|
||
if (suppressions <= 0 || left <= 0) {
|
||
continue;
|
||
}
|
||
auto &health = RouteHealthByDomain[parts[0]];
|
||
health.suppressions = suppressions;
|
||
health.suppressedUntil = now + left;
|
||
LOG(("WSS Route: %1 suppressed for %2 s (restored after restart)."
|
||
).arg(parts[0]
|
||
).arg(left / 1000));
|
||
}
|
||
}
|
||
|
||
void SaveRouteHealthLocked() {
|
||
auto file = QFile(RouteHealthPath());
|
||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||
return;
|
||
}
|
||
const auto now = crl::now();
|
||
const auto wall = QDateTime::currentMSecsSinceEpoch();
|
||
auto data = QByteArray();
|
||
for (const auto &[domain, health] : RouteHealthByDomain) {
|
||
if (health.suppressions > 0 && health.suppressedUntil > now) {
|
||
data += domain.toUtf8()
|
||
+ ' ' + QByteArray::number(health.suppressions)
|
||
+ ' ' + QByteArray::number(
|
||
wall + (health.suppressedUntil - now))
|
||
+ '\n';
|
||
}
|
||
}
|
||
file.write(data);
|
||
}
|
||
std::map<QString, crl::time> TcpSuccessByHost;
|
||
|
||
void NoteTcpConnected(const QString &host) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
TcpSuccessByHost[host] = crl::now();
|
||
}
|
||
|
||
[[nodiscard]] bool TcpRecentlyConnected(const QString &host) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = TcpSuccessByHost.find(host);
|
||
return (i != end(TcpSuccessByHost))
|
||
&& (crl::now() - i->second < kRecentTcpSuccess);
|
||
}
|
||
|
||
[[nodiscard]] bool RouteSuppressed(const QString &domain) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
LoadRouteHealthLocked();
|
||
const auto i = RouteHealthByDomain.find(domain);
|
||
if (i == end(RouteHealthByDomain) || !i->second.suppressedUntil) {
|
||
return false;
|
||
} else if (i->second.suppressedUntil <= crl::now()) {
|
||
const auto suppressions = i->second.suppressions;
|
||
i->second = RouteHealth();
|
||
i->second.suppressions = suppressions;
|
||
LOG(("WSS Route: %1 restored (suppression expired).").arg(domain));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void NoteRouteUnreachable(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
LoadRouteHealthLocked();
|
||
auto &health = RouteHealthByDomain[route.domain];
|
||
// Media relays used to go to the tunnel after their first failure, for
|
||
// half an hour. Connections open in bursts, so a first failure is almost
|
||
// guaranteed, and on a throttled network the tunnel freezes after ~16 KB:
|
||
// in Android logs (10) every DC1/DC5 media session through it died while
|
||
// kws2-1 and kws4-1 delivered megabytes. Media follows the main rules.
|
||
const auto now = crl::now();
|
||
if (health.suppressedUntil > now) {
|
||
return;
|
||
} else if (health.lastFailureAt
|
||
&& (now - health.lastFailureAt < kRouteFailureCoalesce)) {
|
||
return;
|
||
}
|
||
health.lastFailureAt = now;
|
||
++health.consecutiveFailures;
|
||
LOG(("WSS Route: %1 failure %2/%3."
|
||
).arg(route.domain
|
||
).arg(health.consecutiveFailures
|
||
).arg(kRouteFailuresBeforeSuppress));
|
||
if (health.consecutiveFailures >= kRouteFailuresBeforeSuppress) {
|
||
const auto ttl = std::min(
|
||
kRouteSuppressTtl << std::min(health.suppressions, 4),
|
||
kRouteSuppressMaxTtl);
|
||
++health.suppressions;
|
||
health.suppressedUntil = now + ttl;
|
||
SaveRouteHealthLocked();
|
||
LOG(("WSS Route: %1 suppressed for %2 s, next: %3."
|
||
).arg(route.domain
|
||
).arg(ttl / 1000
|
||
).arg(route.tunnel ? u"direct TCP"_q : u"Cloudflare tunnel"_q));
|
||
}
|
||
}
|
||
|
||
void NoteRouteReachable(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
auto &health = RouteHealthByDomain[route.domain];
|
||
if (health.consecutiveFailures || health.suppressedUntil) {
|
||
LOG(("WSS Route: %1 restored (relay answered).").arg(route.domain));
|
||
}
|
||
const auto persisted = (health.suppressions > 0);
|
||
health = RouteHealth();
|
||
if (persisted) {
|
||
SaveRouteHealthLocked();
|
||
}
|
||
}
|
||
|
||
void NoteRelayAttemptFailed(const WssRoute &route, bool viaFallback) {
|
||
if (!HasRelayFallback(route)) {
|
||
return;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
if (viaFallback) {
|
||
RelayPreferences.erase(RelayPreferenceKey(route));
|
||
} else {
|
||
RelayPreferences[RelayPreferenceKey(route)] = {
|
||
.preferFallback = true,
|
||
.until = crl::now() + kRelayFallbackPreferenceTtl,
|
||
};
|
||
}
|
||
}
|
||
|
||
void NoteRelayUpgraded(const WssRoute &route, bool viaFallback) {
|
||
if (!HasRelayFallback(route)) {
|
||
return;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
if (viaFallback) {
|
||
RelayPreferences[RelayPreferenceKey(route)] = {
|
||
.preferFallback = true,
|
||
.until = crl::now() + kRelayFallbackPreferenceTtl,
|
||
};
|
||
} else {
|
||
RelayPreferences.erase(RelayPreferenceKey(route));
|
||
}
|
||
}
|
||
|
||
struct RotatedTunnelSockets {
|
||
int count = 0;
|
||
qint64 sent = 0;
|
||
qint64 received = 0;
|
||
crl::time life = 0;
|
||
crl::time since = 0;
|
||
};
|
||
|
||
QMutex RotatedTunnelSocketsMutex;
|
||
RotatedTunnelSockets RotatedTunnelSocketsSum;
|
||
|
||
void NoteRotatedTunnelSocket(
|
||
not_null<RuntimeEnvironment*> runtime,
|
||
qint64 sent,
|
||
qint64 received,
|
||
crl::time life) {
|
||
constexpr auto kReportEvery = 32;
|
||
constexpr auto kReportAfter = 60 * crl::time(1000);
|
||
auto report = RotatedTunnelSockets();
|
||
{
|
||
QMutexLocker lock(&RotatedTunnelSocketsMutex);
|
||
auto &sum = RotatedTunnelSocketsSum;
|
||
const auto now = crl::now();
|
||
if (!sum.count) {
|
||
sum.since = now;
|
||
}
|
||
++sum.count;
|
||
sum.sent += sent;
|
||
sum.received += received;
|
||
sum.life += life;
|
||
if (sum.count < kReportEvery && now - sum.since < kReportAfter) {
|
||
return;
|
||
}
|
||
report = base::take(sum);
|
||
}
|
||
WriteProxyDiagnosticsLine(runtime, {
|
||
.source = ProxyDiagnosticsSource::Network,
|
||
.phase = ProxyDiagnosticsPhase::AttemptSummary,
|
||
.severity = ProxyDiagnosticsSeverity::Info,
|
||
.transport = u"WSSTunnel"_q,
|
||
.message = u"wss_tunnel_rotated sockets=%1 tx=%2 rx=%3 avg_life_ms=%4"_q
|
||
.arg(report.count)
|
||
.arg(report.sent)
|
||
.arg(report.received)
|
||
.arg(report.life / report.count),
|
||
.route = u"edge.amberwick.workers.dev"_q,
|
||
});
|
||
}
|
||
|
||
[[nodiscard]] QByteArray RandomBytes(int count) {
|
||
auto result = QByteArray(count, char(0));
|
||
bytes::set_random(bytes::make_detached_span(result));
|
||
return result;
|
||
}
|
||
|
||
[[nodiscard]] QString OfficialRelayIngress(int dcId) {
|
||
// Each ingress serves only its own datacenters; they are not
|
||
// interchangeable. Verified against live relays on 2026-08-08: all three
|
||
// accept the upgrade and carry real MTProto, including the media (-1)
|
||
// hostnames.
|
||
switch (dcId) {
|
||
case 1:
|
||
case 3: return u"149.154.174.100"_q;
|
||
case 2:
|
||
case 4: return u"149.154.167.220"_q;
|
||
case 5: return u"149.154.170.100"_q;
|
||
}
|
||
return QString();
|
||
}
|
||
|
||
[[nodiscard]] std::optional<WssRoute> OfficialRoute(int16 protocolDcId) {
|
||
const auto raw = int(protocolDcId);
|
||
const auto positive = (raw < 0) ? -raw : raw;
|
||
if (positive >= kTestModeDcIdShift) {
|
||
return std::nullopt; // test-mode DCs have no public web relay
|
||
}
|
||
const auto ingress = OfficialRelayIngress(positive);
|
||
if (ingress.isEmpty()) {
|
||
return std::nullopt; // CDN and unknown ids have no public web relay
|
||
}
|
||
auto route = WssRoute();
|
||
route.relayHost = ingress;
|
||
route.relayPort = 443;
|
||
route.path = u"/apiws"_q;
|
||
const auto name = u"kws%1"_q.arg(positive);
|
||
// A file lane can bootstrap a regular key first. Route by the protocol
|
||
// DC sign, not by its large-buffer/file-lane classification, otherwise a
|
||
// positive DC id reaches a media-only relay and is rejected with -444.
|
||
route.domain = (raw < 0)
|
||
? (name + u"-1.web.telegram.org"_q)
|
||
: (name + u".web.telegram.org"_q);
|
||
// Fallback: if the hardcoded relay IP is unreachable, retry once via the
|
||
// domain so DNS yields a currently-valid address.
|
||
route.relayHostFallback = route.domain;
|
||
return route;
|
||
}
|
||
|
||
[[nodiscard]] WssRoute TunnelRoute() {
|
||
auto route = WssRoute();
|
||
route.relayHost = u"edge.amberwick.workers.dev"_q;
|
||
route.relayPort = 443;
|
||
route.domain = route.relayHost;
|
||
route.path = u"/apiws"_q;
|
||
route.tunnel = true;
|
||
return route;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
std::optional<WssRoute> WssOfficialRoute(int16 protocolDcId) {
|
||
auto route = OfficialRoute(protocolDcId);
|
||
if (!route) {
|
||
// DC203 serves non-Premium media and has no kws relay at all.
|
||
const auto raw = int(protocolDcId);
|
||
if (raw == kTunnelOnlyDcId || raw == -kTunnelOnlyDcId) {
|
||
auto tunnel = TunnelRoute();
|
||
if (!RouteSuppressed(tunnel.domain)) {
|
||
return tunnel;
|
||
}
|
||
}
|
||
return std::nullopt;
|
||
}
|
||
if (RouteSuppressed(route->domain)) {
|
||
// Релей датацентра недоступен: сначала туннель, потом прямой TCP.
|
||
auto tunnel = TunnelRoute();
|
||
if (RouteSuppressed(tunnel.domain)) {
|
||
return std::nullopt;
|
||
}
|
||
return tunnel;
|
||
}
|
||
return route;
|
||
}
|
||
|
||
bool WssMediaTunneled(int dcId) {
|
||
const auto media = OfficialRoute(int16(-dcId));
|
||
if (!media) {
|
||
return (dcId == kTunnelOnlyDcId)
|
||
&& !RouteSuppressed(TunnelRoute().domain);
|
||
}
|
||
return RouteSuppressed(media->domain)
|
||
&& !RouteSuppressed(TunnelRoute().domain);
|
||
}
|
||
|
||
std::optional<WssRoute> WssCustomRoute(const ProxyStealthOptions &stealth) {
|
||
if (stealth.wssCustomHost.isEmpty()) {
|
||
return std::nullopt;
|
||
}
|
||
auto route = WssRoute();
|
||
route.relayHost = stealth.wssCustomHost;
|
||
route.relayPort = (stealth.wssCustomPort > 0 && stealth.wssCustomPort <= 65535)
|
||
? stealth.wssCustomPort
|
||
: 443;
|
||
route.path = stealth.wssCustomPath.isEmpty()
|
||
? u"/apiws"_q
|
||
: stealth.wssCustomPath;
|
||
route.domain = stealth.wssCustomDomain.isEmpty()
|
||
? stealth.wssCustomHost
|
||
: stealth.wssCustomDomain;
|
||
if (route.domain != route.relayHost) {
|
||
route.relayHostFallback = route.domain;
|
||
}
|
||
return route;
|
||
}
|
||
|
||
WssRouteDiagnostics WssRouteDiagnosticsForDc(
|
||
const ProxyStealthOptions &stealth,
|
||
int16 protocolDcId) {
|
||
auto result = WssRouteDiagnostics();
|
||
result.route = WssCustomRoute(stealth);
|
||
result.custom = result.route.has_value();
|
||
if (!result.route) {
|
||
result.route = OfficialRoute(protocolDcId);
|
||
}
|
||
if (!result.route) {
|
||
return result;
|
||
}
|
||
const auto &route = *result.route;
|
||
result.prefersFallback = PreferRelayFallback(route);
|
||
result.selectedRelayHost = result.prefersFallback
|
||
? route.relayHostFallback
|
||
: route.relayHost;
|
||
if (result.custom) {
|
||
return result;
|
||
}
|
||
result.suppressed = RouteSuppressed(route.domain);
|
||
{
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = RouteHealthByDomain.find(route.domain);
|
||
if (i != end(RouteHealthByDomain)) {
|
||
result.consecutiveFailures = i->second.consecutiveFailures;
|
||
result.suppressedFor = std::max(
|
||
i->second.suppressedUntil - crl::now(),
|
||
crl::time(0));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
WssSocket::WssSocket(
|
||
not_null<RuntimeEnvironment*> runtime,
|
||
not_null<QThread*> thread,
|
||
const QNetworkProxy &proxy,
|
||
bool protocolForFiles,
|
||
WssRoute route)
|
||
: AbstractSocket(runtime, thread)
|
||
, _route(std::move(route)) {
|
||
_socket.moveToThread(thread);
|
||
_socket.setProxy(proxy);
|
||
_socket.setPeerVerifyMode(QSslSocket::VerifyPeer);
|
||
_forFiles = protocolForFiles;
|
||
if (protocolForFiles) {
|
||
_socket.setSocketOption(
|
||
QAbstractSocket::SendBufferSizeSocketOption,
|
||
kFilesSendBufferSize);
|
||
_socket.setSocketOption(
|
||
QAbstractSocket::ReceiveBufferSizeSocketOption,
|
||
kFilesReceiveBufferSize);
|
||
}
|
||
const auto wrap = [&](auto handler) {
|
||
return [=](auto &&...args) {
|
||
InvokeQueued(this, [=] { handler(args...); });
|
||
};
|
||
};
|
||
using Error = QAbstractSocket::SocketError;
|
||
connect(
|
||
&_socket,
|
||
&QAbstractSocket::connected,
|
||
wrap([=] { onTcpConnected(); }));
|
||
connect(
|
||
&_socket,
|
||
&QSslSocket::encrypted,
|
||
wrap([=] { onEncrypted(); }));
|
||
connect(
|
||
&_socket,
|
||
&QSslSocket::disconnected,
|
||
wrap([=] { _disconnected.fire({}); }));
|
||
connect(
|
||
&_socket,
|
||
&QSslSocket::readyRead,
|
||
wrap([=] { onReadyRead(); }));
|
||
connect(
|
||
&_socket,
|
||
&QAbstractSocket::errorOccurred,
|
||
wrap([=](Error e) { handleError(e); }));
|
||
}
|
||
|
||
WssSocket::~WssSocket() {
|
||
if (!_openedAt) {
|
||
return;
|
||
} else if (_rotated) {
|
||
// A tunnel file connection lives for one part; a line for each of
|
||
// them would flood the log, so they are summed up instead.
|
||
NoteRotatedTunnelSocket(
|
||
_runtime,
|
||
_bytesSent,
|
||
_bytesReceived,
|
||
crl::now() - _openedAt);
|
||
return;
|
||
}
|
||
// One line per relay socket: which route it took, how far the handshake
|
||
// got, how much went each way and how long it lived.
|
||
const auto since = [&](crl::time at) {
|
||
return at ? QString::number(at - _openedAt) : u"-1"_q;
|
||
};
|
||
WriteProxyDiagnosticsLine(_runtime, {
|
||
.source = ProxyDiagnosticsSource::Network,
|
||
.phase = ProxyDiagnosticsPhase::AttemptSummary,
|
||
.severity = ProxyDiagnosticsSeverity::Info,
|
||
.transport = _route.tunnel ? u"WSSTunnel"_q : u"WSS"_q,
|
||
.socketId = _debugId,
|
||
.message = u"wss_session host=%1 tcp=%2 upgraded=%3 tx=%4 rx=%5 ready_ms=%6 first_data_ms=%7 life_ms=%8"_q
|
||
.arg(_currentHost)
|
||
.arg(_tcpConnected ? 1 : 0)
|
||
.arg(_upgraded ? 1 : 0)
|
||
.arg(_bytesSent)
|
||
.arg(_bytesReceived)
|
||
.arg(since(_upgradedAt))
|
||
.arg(since(_firstDataAt))
|
||
.arg(crl::now() - _openedAt),
|
||
.route = _route.domain,
|
||
});
|
||
}
|
||
|
||
void WssSocket::connectToHost(const QString &address, int port) {
|
||
Q_UNUSED(port);
|
||
// MTProto-over-WSS always connects to the relay route; the DC
|
||
// endpoint (address, port) is intentionally ignored - the relay routes
|
||
// to the right data center based on the SNI / Host domain.
|
||
if (_route.tunnel) {
|
||
_route.path = u"/apiws?dst="_q + address;
|
||
}
|
||
_usedFallback = PreferRelayFallback(_route);
|
||
_openedAt = crl::now();
|
||
connectToRelayHost();
|
||
}
|
||
|
||
void WssSocket::connectToRelayHost() {
|
||
const auto host = _usedFallback ? _route.relayHostFallback : _route.relayHost;
|
||
_currentHost = host;
|
||
_tcpConnected = false;
|
||
_socket.setPeerVerifyName(_route.domain);
|
||
_socket.connectToHostEncrypted(
|
||
host,
|
||
quint16(_route.relayPort),
|
||
_route.domain);
|
||
}
|
||
|
||
bool WssSocket::takeRotation() {
|
||
const auto limit = _forFiles ? kTunnelRotateBytes : kTunnelMainRotateBytes;
|
||
if (!_route.tunnel || _bytesReceived < limit) {
|
||
return false;
|
||
}
|
||
_rotated = true;
|
||
return true;
|
||
}
|
||
|
||
bool WssSocket::isGoodStartNonce(bytes::const_span nonce) {
|
||
Expects(nonce.size() >= 2 * sizeof(uint32));
|
||
|
||
const auto zero = binary::Read<uchar>(nonce);
|
||
const auto first = binary::Read<uint32>(nonce);
|
||
const auto second = binary::ReadAt<uint32>(nonce, sizeof(uint32));
|
||
const auto reserved01 = 0x000000EFU;
|
||
const auto reserved11 = 0x44414548U;
|
||
const auto reserved12 = 0x54534F50U;
|
||
const auto reserved13 = 0x20544547U;
|
||
const auto reserved14 = 0xEEEEEEEEU;
|
||
const auto reserved15 = 0xDDDDDDDDU;
|
||
const auto reserved16 = 0x02010316U;
|
||
const auto reserved21 = 0x00000000U;
|
||
return (zero != reserved01)
|
||
&& (first != reserved11)
|
||
&& (first != reserved12)
|
||
&& (first != reserved13)
|
||
&& (first != reserved14)
|
||
&& (first != reserved15)
|
||
&& (first != reserved16)
|
||
&& (second != reserved21);
|
||
}
|
||
|
||
void WssSocket::timedOut() {
|
||
if (_upgraded) {
|
||
if (_route.tunnel
|
||
&& !_bytesReceived
|
||
&& crl::now() - _upgradedAt >= kTunnelSilentAfter) {
|
||
// The tunnel upgraded and then delivered nothing at all. A tunnel
|
||
// that froze after some data is throttled, not dead: suppressing
|
||
// it sent DC1 to direct TCP, which the same network blocks
|
||
// outright, and files did not load at all for two minutes.
|
||
NoteRouteUnreachable(_route);
|
||
logError(0, u"WSS tunnel silent after upgrade"_q);
|
||
}
|
||
return;
|
||
}
|
||
if (!_tcpConnected && TcpRecentlyConnected(_currentHost)) {
|
||
// Соседние сокеты к этому адресу только что подключались: провайдер
|
||
// съел SYN одного потока. Новый сокет пройдёт, а переход на запасной
|
||
// адрес или в туннель здесь только навредит.
|
||
return;
|
||
}
|
||
// The session watchdog is killing this socket before any socket error
|
||
// arrived. Remember which relay host stalled so the next socket starts
|
||
// from the other one instead of repeating the same dead-host attempt.
|
||
if (!_upgraded && !_hostFlipped) {
|
||
NoteRelayAttemptFailed(_route, _usedFallback);
|
||
}
|
||
if (!_upgraded && !_tcpConnected) {
|
||
// Не дошли даже до установленного TCP: адрес релея недоступен, а не
|
||
// протокол сломан.
|
||
NoteRouteUnreachable(_route);
|
||
}
|
||
}
|
||
|
||
bool WssSocket::isConnected() {
|
||
return _upgraded
|
||
&& (_socket.state() == QAbstractSocket::ConnectedState);
|
||
}
|
||
|
||
bool WssSocket::hasBytesAvailable() {
|
||
return !_readBuffer.isEmpty();
|
||
}
|
||
|
||
int64 WssSocket::read(bytes::span buffer) {
|
||
const auto count = std::min(
|
||
int64(buffer.size()),
|
||
int64(_readBuffer.size()));
|
||
if (count <= 0) {
|
||
return 0;
|
||
}
|
||
binary::Copy(
|
||
buffer,
|
||
bytes::make_span(_readBuffer.constData(), count));
|
||
_readBuffer.remove(0, int(count));
|
||
return count;
|
||
}
|
||
|
||
void WssSocket::write(bytes::const_span prefix, bytes::const_span buffer) {
|
||
Expects(!buffer.empty());
|
||
|
||
// Frame boundaries are free EXCEPT for the very first binary frame after
|
||
// the upgrade: the relay parses the 64-byte obfuscation header out of that
|
||
// single frame's payload and never revisits the decision. A shorter first
|
||
// frame is fatal and silent - the relay simply never answers, which looks
|
||
// exactly like a network problem. Neither one TCP write nor real WebSocket
|
||
// fragmentation helps; only the frame payload counts. Measured against the
|
||
// live relays on 2026-08-08: 63 bytes never answered, 64 always did.
|
||
// Combining the header with the first packet keeps that guarantee here.
|
||
if (prefix.empty()) {
|
||
sendFrame(0x2, buffer);
|
||
return;
|
||
}
|
||
auto combined = bytes::vector(prefix.size() + buffer.size());
|
||
auto combinedBytes = bytes::make_span(combined);
|
||
binary::Copy(combinedBytes, prefix);
|
||
binary::Copy(combinedBytes.subspan(prefix.size()), buffer);
|
||
sendFrame(0x2, bytes::make_span(combined));
|
||
}
|
||
|
||
int32 WssSocket::debugState() {
|
||
return _socket.state();
|
||
}
|
||
|
||
QString WssSocket::debugPostfix() const {
|
||
return u"WS"_q;
|
||
}
|
||
|
||
HandshakePhase WssSocket::handshakePhase() const {
|
||
return _phase;
|
||
}
|
||
|
||
QString WssSocket::transportName() const {
|
||
return u"WSS"_q;
|
||
}
|
||
|
||
void WssSocket::handleError(int errorCode) {
|
||
// On a connect/handshake failure, retry once via the other relay host
|
||
// (hardcoded IP <-> domain) before giving up, so a blocked or stale
|
||
// relay IP does not kill web-relay connectivity. The failure is recorded
|
||
// so the next socket starts from the host that still may work.
|
||
if (!_upgraded && !_hostFlipped && HasRelayFallback(_route)) {
|
||
NoteRelayAttemptFailed(_route, _usedFallback);
|
||
_hostFlipped = true;
|
||
_usedFallback = !_usedFallback;
|
||
_incoming = QByteArray();
|
||
_phase = HandshakePhase::None;
|
||
_socket.abort();
|
||
connectToRelayHost();
|
||
return;
|
||
}
|
||
if (!_upgraded && !_hostFlipped) {
|
||
NoteRelayAttemptFailed(_route, _usedFallback);
|
||
}
|
||
logError(errorCode, _socket.errorString());
|
||
_error.fire_copy(errorCode);
|
||
}
|
||
|
||
void WssSocket::onTcpConnected() {
|
||
_tcpConnected = true;
|
||
NoteTcpConnected(_currentHost);
|
||
}
|
||
|
||
void WssSocket::onEncrypted() {
|
||
_phase = HandshakePhase::TcpConnected;
|
||
connectionProgress(_phase);
|
||
sendHttpUpgrade();
|
||
_phase = HandshakePhase::ClientHelloSent;
|
||
connectionProgress(_phase);
|
||
}
|
||
|
||
void WssSocket::sendHttpUpgrade() {
|
||
_secWebSocketKey = QString::fromLatin1(RandomBytes(16).toBase64());
|
||
auto host = _route.domain;
|
||
if (_route.relayPort != 443) {
|
||
host += u":%1"_q.arg(_route.relayPort);
|
||
}
|
||
const auto request = (u"GET %1 HTTP/1.1\r\n"
|
||
u"Host: %2\r\n"
|
||
u"Upgrade: websocket\r\n"
|
||
u"Connection: Upgrade\r\n"
|
||
u"Sec-WebSocket-Key: %3\r\n"
|
||
u"Sec-WebSocket-Version: 13\r\n"
|
||
u"Sec-WebSocket-Protocol: binary\r\n"
|
||
u"Origin: https://web.telegram.org\r\n"
|
||
u"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
u"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
u"Chrome/131.0.0.0 Safari/537.36\r\n"
|
||
u"\r\n"_q).arg(_route.path, host, _secWebSocketKey);
|
||
const auto utf8 = request.toUtf8();
|
||
_socket.write(utf8);
|
||
}
|
||
|
||
bool WssSocket::tryFinishUpgrade() {
|
||
const auto end = _incoming.indexOf("\r\n\r\n");
|
||
if (end < 0) {
|
||
if (_incoming.size() > kWssHeaderLimit) {
|
||
logError(0, u"WSS HTTP response too large"_q);
|
||
_error.fire_copy(AbstractConnection::kErrorCodeOther);
|
||
}
|
||
return false;
|
||
}
|
||
const auto header = _incoming.left(end);
|
||
_incoming.remove(0, end + 4);
|
||
if (!header.contains(" 101 ") && !header.contains(" 101\r")) {
|
||
logError(0, u"WSS HTTP upgrade rejected"_q);
|
||
_error.fire_copy(AbstractConnection::kErrorCodeOther);
|
||
return false;
|
||
}
|
||
if (!checkUpgradeAccept(header)) {
|
||
logError(0, u"WSS Sec-WebSocket-Accept mismatch"_q);
|
||
_error.fire_copy(AbstractConnection::kErrorCodeOther);
|
||
return false;
|
||
}
|
||
_upgraded = true;
|
||
_upgradedAt = crl::now();
|
||
if (!_route.tunnel) {
|
||
// The Cloudflare tunnel upgrades fine and then freezes after ~16 KB
|
||
// on a throttled network; it proves itself in parseFrames instead.
|
||
NoteRouteReachable(_route);
|
||
}
|
||
NoteRelayUpgraded(_route, _usedFallback);
|
||
_phase = HandshakePhase::ServerHelloOk;
|
||
connectionProgress(_phase);
|
||
_connected.fire({});
|
||
return true;
|
||
}
|
||
|
||
bool WssSocket::checkUpgradeAccept(const QByteArray &header) const {
|
||
const auto expected = QCryptographicHash::hash(
|
||
_secWebSocketKey.toLatin1()
|
||
+ QByteArrayLiteral("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"),
|
||
QCryptographicHash::Sha1).toBase64();
|
||
const auto lowered = header.toLower();
|
||
const auto marker = QByteArrayLiteral("sec-websocket-accept:");
|
||
const auto pos = lowered.indexOf(marker);
|
||
if (pos < 0) {
|
||
return false;
|
||
}
|
||
auto valueEnd = header.indexOf('\n', pos);
|
||
if (valueEnd < 0) {
|
||
valueEnd = header.size();
|
||
}
|
||
const auto from = pos + marker.size();
|
||
const auto value = header.mid(from, valueEnd - from).trimmed();
|
||
return (value == expected);
|
||
}
|
||
|
||
void WssSocket::onReadyRead() {
|
||
_incoming += _socket.readAll();
|
||
if (!_upgraded && !tryFinishUpgrade()) {
|
||
return;
|
||
}
|
||
if (_upgraded) {
|
||
parseFrames();
|
||
}
|
||
}
|
||
|
||
void WssSocket::parseFrames() {
|
||
auto produced = false;
|
||
auto offset = 0;
|
||
const auto total = int(_incoming.size());
|
||
const auto data = bytes::make_span(_incoming.constData(), total);
|
||
const auto byteAt = [&](int index) {
|
||
return gsl::to_integer<quint8>(data[index]);
|
||
};
|
||
while (total - offset >= 2) {
|
||
const auto opcode = (byteAt(offset) & 0x0f);
|
||
const auto masked = ((byteAt(offset + 1) & 0x80) != 0);
|
||
auto length = quint64(byteAt(offset + 1) & 0x7f);
|
||
auto headerLen = 2;
|
||
if (length == 126) {
|
||
if (total - offset < 4) {
|
||
break;
|
||
}
|
||
length = (quint64(byteAt(offset + 2)) << 8)
|
||
| quint64(byteAt(offset + 3));
|
||
headerLen = 4;
|
||
} else if (length == 127) {
|
||
if (total - offset < 10) {
|
||
break;
|
||
}
|
||
length = 0;
|
||
for (auto i = 0; i != 8; ++i) {
|
||
length = (length << 8) | quint64(byteAt(offset + 2 + i));
|
||
}
|
||
headerLen = 10;
|
||
}
|
||
if (length > kWssMaxFrame) {
|
||
logError(0, u"WSS frame too large"_q);
|
||
_error.fire_copy(AbstractConnection::kErrorCodeOther);
|
||
return;
|
||
}
|
||
const auto maskLen = masked ? 4 : 0;
|
||
const auto frameLen = quint64(headerLen) + maskLen + length;
|
||
if (quint64(total - offset) < frameLen) {
|
||
break;
|
||
}
|
||
const auto mask = data.subspan(offset + headerLen, maskLen);
|
||
const auto payload = data.subspan(
|
||
offset + headerLen + maskLen,
|
||
int(length));
|
||
if (opcode == 0x8) { // close
|
||
logError(0, u"WSS close frame received"_q);
|
||
_error.fire_copy(AbstractConnection::kErrorCodeOther);
|
||
return;
|
||
} else if (opcode == 0x9) { // ping -> pong
|
||
sendFrame(0xA, payload);
|
||
} else if (opcode == 0x0 || opcode == 0x1 || opcode == 0x2) {
|
||
if (length > 0) {
|
||
_bytesReceived += qint64(length);
|
||
if (_route.tunnel
|
||
&& !_tunnelProven
|
||
&& _bytesReceived >= kTunnelRotateBytes) {
|
||
_tunnelProven = true;
|
||
NoteRouteReachable(_route);
|
||
}
|
||
const auto at = int(_readBuffer.size());
|
||
const auto count = int(length);
|
||
_readBuffer.resize(at + count);
|
||
auto out = bytes::make_detached_span(_readBuffer).subspan(at);
|
||
if (masked) {
|
||
for (auto i = 0; i != count; ++i) {
|
||
out[i] = bytes::type(
|
||
byteAt(offset + headerLen + maskLen + i)
|
||
^ gsl::to_integer<quint8>(mask[i % 4]));
|
||
}
|
||
} else {
|
||
binary::Copy(out, payload);
|
||
}
|
||
produced = true;
|
||
}
|
||
}
|
||
offset += int(frameLen);
|
||
}
|
||
if (offset > 0) {
|
||
_incoming.remove(0, offset);
|
||
}
|
||
if (produced) {
|
||
if (_phase == HandshakePhase::ServerHelloOk) {
|
||
_phase = HandshakePhase::FirstDataReceived;
|
||
_firstDataAt = crl::now();
|
||
connectionProgress(_phase);
|
||
}
|
||
_readyRead.fire({});
|
||
}
|
||
}
|
||
|
||
void WssSocket::sendFrame(quint8 opcode, bytes::const_span data) {
|
||
const auto size = int(data.size());
|
||
if (opcode == 0x2) {
|
||
_bytesSent += size;
|
||
}
|
||
auto frame = QByteArray();
|
||
frame.reserve(size + 14);
|
||
frame.append(char(0x80 | opcode));
|
||
if (size < 126) {
|
||
frame.append(char(0x80 | size));
|
||
} else if (size <= 0xffff) {
|
||
frame.append(char(0x80 | 126));
|
||
frame.append(char((size >> 8) & 0xff));
|
||
frame.append(char(size & 0xff));
|
||
} else {
|
||
frame.append(char(0x80 | 127));
|
||
for (auto i = 7; i >= 0; --i) {
|
||
frame.append(char((quint64(size) >> (i * 8)) & 0xff));
|
||
}
|
||
}
|
||
const auto mask = RandomBytes(4);
|
||
frame.append(mask);
|
||
const auto maskBytes = bytes::make_span(mask);
|
||
const auto base = int(frame.size());
|
||
frame.resize(base + size);
|
||
auto out = bytes::make_detached_span(frame).subspan(base);
|
||
for (auto i = 0; i != size; ++i) {
|
||
out[i] = bytes::type(
|
||
gsl::to_integer<quint8>(data[i])
|
||
^ gsl::to_integer<quint8>(maskBytes[i % 4]));
|
||
}
|
||
_socket.write(frame);
|
||
}
|
||
|
||
} // namespace MTP::details
|