All checks were successful
Desktop source guards / guards (push) Successful in 7s
В этом форке rpl::start_with_next нет (сборка dev-45 упала на socket.cpp, C2039): события вложенного TcpSocket пересылаются так же, как в connection_tcp.cpp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1844 lines
60 KiB
C++
1844 lines
60 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/transport/details/mtproto_tcp_socket.h"
|
||
#include "mtproto/proxy/diagnostics.h"
|
||
#include "logs.h"
|
||
#include "base/bytes.h"
|
||
#include "base/invoke_queued.h"
|
||
#include "base/random.h"
|
||
|
||
#include <crl/crl_time.h>
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cstring>
|
||
#include <algorithm>
|
||
#include <map>
|
||
|
||
#include <QtCore/QCryptographicHash>
|
||
#include <QtCore/QMutex>
|
||
#include <QtCore/QDateTime>
|
||
#include <QtCore/QFile>
|
||
#include <QtNetwork/QNetworkInformation>
|
||
|
||
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;
|
||
|
||
std::atomic<bool> CurrentNetworkMetered = false;
|
||
|
||
[[nodiscard]] QString NetworkKey(bool metered, const QString &value) {
|
||
return (metered ? u"metered/"_q : u"unmetered/"_q) + value;
|
||
}
|
||
|
||
// Telegram Web ingress addresses. Each serves only its own datacenters; they
|
||
// are not interchangeable (verified against live relays 2026-08-08). DC2 and
|
||
// DC4 have two: DNS names 149.154.167.99 (web.telegram.org too), while
|
||
// 149.154.167.220 is the one every client hardcoded for months. Both carried
|
||
// all four kws2/kws4 names on 16.10.2026, 25 rounds each, with no difference
|
||
// beyond noise. A provider blocking one of them by IP (tg-ws-proxy issue
|
||
// #1450, 15.10.2026) leaves the other, which unlike the DNS fallback does
|
||
// not depend on the resolver.
|
||
[[nodiscard]] const QStringList &OfficialRelayIngresses(int dcId) {
|
||
static const auto dc13 = QStringList{ u"149.154.174.100"_q };
|
||
static const auto dc24 = QStringList{
|
||
u"149.154.167.220"_q,
|
||
u"149.154.167.99"_q,
|
||
};
|
||
static const auto dc5 = QStringList{ u"149.154.170.100"_q };
|
||
static const auto none = QStringList();
|
||
switch (dcId) {
|
||
case 1:
|
||
case 3: return dc13;
|
||
case 2:
|
||
case 4: return dc24;
|
||
case 5: return dc5;
|
||
}
|
||
return none;
|
||
}
|
||
|
||
// Which ingress of a DC last answered on a network, and when each one failed.
|
||
// A dead address hands the DC to the next ingress before the DNS name.
|
||
// Guarded by RelayPreferencesMutex.
|
||
struct RelayAddressState {
|
||
QString preferred;
|
||
std::map<QString, crl::time> failedAt;
|
||
};
|
||
std::map<QString, RelayAddressState> RelayAddressStates;
|
||
|
||
[[nodiscard]] QString RelayAddressStateKey(bool metered, int dcId) {
|
||
const auto &addresses = OfficialRelayIngresses(dcId);
|
||
return NetworkKey(
|
||
metered,
|
||
u"ingress-"_q + (addresses.isEmpty() ? QString() : addresses.front()));
|
||
}
|
||
|
||
[[nodiscard]] QString RelayPreferenceKey(const WssRoute &route) {
|
||
return NetworkKey(
|
||
route.metered,
|
||
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());
|
||
}
|
||
|
||
// DC203 serves non-Premium media and has no kws relay. The Cloudflare fronts
|
||
// carry it as kws203.<front> (16.10.2026: resPQ from every front tried), so
|
||
// it goes fronts -> tunnel -> direct -> fronts like a DC with a closed relay.
|
||
// As a tunnel-only DC it lost every file once the workers ran out of their
|
||
// daily quota: with the tunnel suppressed the socket factory fell back to
|
||
// unguarded direct TCP, which these networks block (desktop log 16.10, 19:20).
|
||
constexpr auto kNoRelayDcId = 203;
|
||
// Worker tunnels (zastogram-ws-worker/worker.js), each on its own Cloudflare
|
||
// account. A free account serves 100 000 requests a day; past that the worker
|
||
// answers 429 (error code: 1027) until 00:00 UTC, and on 08.10.2026 the single
|
||
// worker was spent by the evening: DC203 media stood still for everyone. Every
|
||
// install starts from a random worker, so the accounts share the load. The
|
||
// same list is in the Android client (jni/tgnet/wss/WssSocket.cpp) and in
|
||
// ZapretGUI (telegram_proxy/proxy/route_catalog.py, TUNNEL_HOSTS).
|
||
constexpr const char *kTunnelHosts[] = {
|
||
"edge.amberwick.workers.dev",
|
||
"fuckyourkn.copperbrook.workers.dev",
|
||
"nodeone.brackencombe.workers.dev",
|
||
};
|
||
constexpr auto kTunnelHostCount = int(std::size(kTunnelHosts));
|
||
|
||
// Отдельный от выбора адреса учёт: у датацентра может не открываться ни один
|
||
// адрес релея — у части провайдеров порт 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);
|
||
|
||
// Cloudflare front domains of tg-ws-proxy (github.com/Flowseal/tg-ws-proxy,
|
||
// .github/cfproxy-domains.txt), in the same shifted spelling: each letter of
|
||
// the name moved forward by the number of letters, ".co.uk" written ".com".
|
||
// kwsN.<domain>/apiws forwards the WebSocket to Telegram Web. Mirrly TG Proxy
|
||
// carries MTProto only this way and, unlike the Worker tunnel (frozen after
|
||
// ~16 KB on mobile networks), works there for text and media. Same catalog
|
||
// and rules as Android (WssSocket.cpp, dev-173/174).
|
||
// Dialled by name, as the tunnel is: DNS gives the zone's current Cloudflare
|
||
// addresses and Qt handles SNI and verification from the one host name.
|
||
// (On dev-31/32 every Cloudflare handshake from the tester's PC was reset
|
||
// ~200 ms after TCP; that was a local zapret strategy, curl.exe was reset
|
||
// the same way.) Android sets SNI itself and dials the addresses.
|
||
constexpr const char *kCdnFronts[] = {
|
||
"virkgj.com",
|
||
"vmmzovy.com",
|
||
"mkuosckvso.com",
|
||
"zaewayzmplad.com",
|
||
"twdmbzcm.com",
|
||
"awzwsldi.com",
|
||
"clngqrflngqin.com",
|
||
"tjacxbqtj.com",
|
||
"bxaxtxmrw.com",
|
||
"dmohrsgmohcrwb.com",
|
||
"vwbmtmoi.com",
|
||
"khgrre.com",
|
||
"ulihssf.com",
|
||
"tmhqsdqmfpmk.com",
|
||
"xwuwoqbm.com",
|
||
"orgcnunpj.com",
|
||
"zhkuldz.com",
|
||
"zypoljnslxa.com",
|
||
"efabnxaowuzs.com",
|
||
"zaftuzsftqdq.com",
|
||
};
|
||
constexpr auto kCdnFrontCount = int(sizeof(kCdnFronts) / sizeof(kCdnFronts[0]));
|
||
// The zones' own Cloudflare addresses (27.09.2026), in kCdnFronts order, as
|
||
// in the Android client and ZapretGUI. Fronts were dialled by name, and the
|
||
// resolver handed out edges in ranges the provider drops: a user's desktop
|
||
// got TCP to a front in 102 attempts of 48 000 on 16.10.2026, in bursts while
|
||
// DNS pointed at a reachable edge. Any Cloudflare edge serves the zone, so a
|
||
// pinned address keeps working; the name stays the fallback host.
|
||
constexpr const char *kCdnFrontAddresses[][2] = {
|
||
{ "104.21.80.254", "172.67.155.165" }, // virkgj.com
|
||
{ "104.21.43.90", "172.67.177.105" }, // vmmzovy.com
|
||
{ "104.21.41.25", "172.67.159.17" }, // mkuosckvso.com
|
||
{ "104.21.70.196", "172.67.138.236" }, // zaewayzmplad.com
|
||
{ "104.21.21.168", "172.67.199.162" }, // twdmbzcm.com
|
||
{ "104.21.69.145", "172.67.209.89" }, // awzwsldi.com
|
||
{ "104.21.73.83", "172.67.189.26" }, // clngqrflngqin.com
|
||
{ "104.21.39.36", "172.67.142.232" }, // tjacxbqtj.com
|
||
{ "104.21.84.223", "172.67.197.117" }, // bxaxtxmrw.com
|
||
{ "104.21.48.178", "172.67.155.85" }, // dmohrsgmohcrwb.com
|
||
{ "104.21.33.146", "172.67.146.105" }, // vwbmtmoi.com
|
||
{ "104.21.78.6", "172.67.214.68" }, // khgrre.com
|
||
{ "104.21.7.253", "172.67.156.145" }, // ulihssf.com
|
||
{ "104.21.25.159", "172.67.134.93" }, // tmhqsdqmfpmk.com
|
||
{ "104.21.44.55", "172.67.195.218" }, // xwuwoqbm.com
|
||
{ "104.21.64.155", "172.67.152.37" }, // orgcnunpj.com
|
||
{ "104.21.51.133", "172.67.180.160" }, // zhkuldz.com
|
||
{ "104.21.37.105", "172.67.207.129" }, // zypoljnslxa.com
|
||
{ "104.21.35.206", "172.67.179.145" }, // efabnxaowuzs.com
|
||
{ "104.21.78.5", "172.67.214.67" }, // zaftuzsftqdq.com
|
||
};
|
||
static_assert(
|
||
sizeof(kCdnFrontAddresses) / sizeof(kCdnFrontAddresses[0])
|
||
== sizeof(kCdnFronts) / sizeof(kCdnFronts[0]),
|
||
"every front needs its two addresses");
|
||
// A front connection is either served at once or answers 503 to every
|
||
// upgrade (host test 27.09); a retry on the same TLS connection costs ~25 ms.
|
||
constexpr auto kCdnUpgradeRetries = 8;
|
||
// About 40% of front connections are refused, so three failures in a row are
|
||
// ordinary; six mean the fronts are really out of reach.
|
||
constexpr auto kCdnFailuresBeforeSuppress = 6;
|
||
// Past the ~16 KB freeze seen on the tunnel: a session that delivered this
|
||
// much proves the front carries real traffic on this network.
|
||
constexpr auto kCdnProofBytes = qint64(32 * 1024);
|
||
// Sessions that answered and then went silent below kCdnProofBytes.
|
||
constexpr auto kCdnStallsBeforeSuppress = 3;
|
||
// Every front attempt rotates to a new address, so "this address connected
|
||
// recently" never matched and each lost SYN counted against all fronts.
|
||
const auto kAnyCdnHost = u"cdn-any"_q;
|
||
|
||
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;
|
||
// Front sessions that answered and then froze (kCdnProofBytes).
|
||
int stalls = 0;
|
||
// The relay upgraded on this network since the process started.
|
||
bool answered = false;
|
||
};
|
||
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]] bool IsCdn(const WssRoute &route) {
|
||
return route.cdnSlot >= 0;
|
||
}
|
||
|
||
[[nodiscard]] const QString &HealthName(const WssRoute &route) {
|
||
return route.healthDomain.isEmpty() ? route.domain : route.healthDomain;
|
||
}
|
||
|
||
// Fronts are checked again on every launch: on Android a restart with the
|
||
// relay, the fronts and the tunnel all restored as suppressed left DC2 with
|
||
// no route at all for ten minutes (logs (1) (8)).
|
||
[[nodiscard]] bool PersistedHealth(const QString &key) {
|
||
return !key.contains(u"/cdn-"_q);
|
||
}
|
||
|
||
[[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);
|
||
// Lines written before the per-network split carry a bare domain.
|
||
if (parts.size() != 3
|
||
|| !parts[0].contains('/')
|
||
|| !PersistedHealth(parts[0])) {
|
||
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
|
||
&& PersistedHealth(domain)) {
|
||
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 WssRoute &route, const QString &host) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto now = crl::now();
|
||
TcpSuccessByHost[NetworkKey(route.metered, host)] = now;
|
||
if (IsCdn(route)) {
|
||
TcpSuccessByHost[NetworkKey(route.metered, kAnyCdnHost)] = now;
|
||
}
|
||
}
|
||
|
||
[[nodiscard]] bool TcpRecentlyConnected(
|
||
const WssRoute &route,
|
||
const QString &host) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = TcpSuccessByHost.find(NetworkKey(route.metered, host));
|
||
return (i != end(TcpSuccessByHost))
|
||
&& (crl::now() - i->second < kRecentTcpSuccess);
|
||
}
|
||
|
||
[[nodiscard]] bool RouteSuppressed(const WssRoute &route) {
|
||
const auto domain = NetworkKey(route.metered, HealthName(route));
|
||
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;
|
||
}
|
||
|
||
// Called with RelayPreferencesMutex held.
|
||
void SuppressLocked(
|
||
const WssRoute &route,
|
||
RouteHealth &health,
|
||
crl::time now,
|
||
const QString &reason) {
|
||
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 (%3), next: %4."
|
||
).arg(NetworkKey(route.metered, HealthName(route))
|
||
).arg(ttl / 1000
|
||
).arg(reason
|
||
).arg(route.direct
|
||
? u"Cloudflare fronts"_q
|
||
: route.tunnel
|
||
? u"direct TCP or Cloudflare fronts"_q
|
||
: IsCdn(route)
|
||
? u"Cloudflare tunnel"_q
|
||
: u"Cloudflare fronts"_q));
|
||
}
|
||
|
||
// Relay domains share ingress addresses: kws2, kws4, kws2-1 and kws4-1 all
|
||
// dial 149.154.167.220. A TCP timeout does not depend on the SNI, so TCP
|
||
// failures also count per address, and a suppressed address moves every
|
||
// domain on it to the fronts at once instead of each timing out on its own
|
||
// (Android logs (23): kws1-1 and kws2-1 lost 8-11 s each after kws1 and kws2
|
||
// had already proven their addresses dead). Same rules as Android dev-176.
|
||
[[nodiscard]] bool IsOfficialRelay(const WssRoute &route) {
|
||
return !route.tunnel
|
||
&& !IsCdn(route)
|
||
&& route.domain.endsWith(u".web.telegram.org"_q);
|
||
}
|
||
|
||
[[nodiscard]] WssRoute RelayAddressRoute(const WssRoute &route) {
|
||
auto result = route;
|
||
result.healthDomain = u"addr-"_q + route.relayHost;
|
||
return result;
|
||
}
|
||
|
||
void NoteRouteUnreachable(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
LoadRouteHealthLocked();
|
||
const auto key = NetworkKey(route.metered, HealthName(route));
|
||
auto &health = RouteHealthByDomain[key];
|
||
// 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;
|
||
const auto limit = IsCdn(route)
|
||
? kCdnFailuresBeforeSuppress
|
||
: kRouteFailuresBeforeSuppress;
|
||
LOG(("WSS Route: %1 failure %2/%3."
|
||
).arg(key
|
||
).arg(health.consecutiveFailures
|
||
).arg(limit));
|
||
if (health.consecutiveFailures >= limit) {
|
||
SuppressLocked(route, health, now, u"failures"_q);
|
||
}
|
||
}
|
||
|
||
// The worker answered 429: its account is out of requests for the day, and
|
||
// every further attempt gets the same answer. Suppressed at once, without
|
||
// counting to three, so the next connection goes straight to the next worker.
|
||
void NoteTunnelQuotaExhausted(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
LoadRouteHealthLocked();
|
||
auto &health = RouteHealthByDomain[
|
||
NetworkKey(route.metered, HealthName(route))];
|
||
const auto now = crl::now();
|
||
if (health.suppressedUntil > now) {
|
||
return;
|
||
}
|
||
health.lastFailureAt = now;
|
||
health.consecutiveFailures = 0;
|
||
SuppressLocked(route, health, now, u"quota"_q);
|
||
}
|
||
|
||
// The relay's hardcoded address failed and its DNS fallback failed too, so
|
||
// the domain went down: siblings on the same address will fail the same way.
|
||
// Only a recent failure of the address itself counts: a stray SYN loss long
|
||
// ago must not take a sibling off a working relay.
|
||
void SuppressRelayAddressWithDomain(const WssRoute &route) {
|
||
if (!IsOfficialRelay(route)) {
|
||
return;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto now = crl::now();
|
||
const auto domain = RouteHealthByDomain.find(
|
||
NetworkKey(route.metered, route.domain));
|
||
if (domain == end(RouteHealthByDomain)
|
||
|| domain->second.suppressedUntil <= now) {
|
||
return;
|
||
}
|
||
// Every ingress the DC went through before the name failed: DC2/DC4
|
||
// try both of theirs first.
|
||
auto addresses = route.relayDcId
|
||
? OfficialRelayIngresses(route.relayDcId)
|
||
: QStringList{ route.relayHost };
|
||
for (const auto &relayAddress : addresses) {
|
||
auto relay = route;
|
||
relay.relayHost = relayAddress;
|
||
const auto address = RelayAddressRoute(relay);
|
||
auto &health = RouteHealthByDomain[
|
||
NetworkKey(route.metered, HealthName(address))];
|
||
if (health.consecutiveFailures > 0
|
||
&& health.suppressedUntil <= now
|
||
&& health.lastFailureAt
|
||
&& (now - health.lastFailureAt < 2 * 60 * crl::time(1000))) {
|
||
SuppressLocked(address, health, now, u"domain"_q);
|
||
}
|
||
}
|
||
}
|
||
|
||
void NoteRouteReachable(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto key = NetworkKey(route.metered, HealthName(route));
|
||
auto &health = RouteHealthByDomain[key];
|
||
if (IsCdn(route)) {
|
||
// The first answer only shows that the front accepts connections. On
|
||
// a network that freezes Cloudflare after ~16 KB every session
|
||
// answers, and resetting the backoff here would cycle frozen sessions
|
||
// and the tunnel forever; that is left to NoteCdnProven.
|
||
health.consecutiveFailures = 0;
|
||
health.lastFailureAt = 0;
|
||
return;
|
||
}
|
||
if (health.consecutiveFailures || health.suppressedUntil) {
|
||
LOG(("WSS Route: %1 restored (relay answered).").arg(key));
|
||
}
|
||
const auto persisted = (health.suppressions > 0);
|
||
health = RouteHealth();
|
||
health.answered = true;
|
||
if (persisted) {
|
||
SaveRouteHealthLocked();
|
||
}
|
||
}
|
||
|
||
void NoteCdnProven(const WssRoute &route) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto key = NetworkKey(route.metered, HealthName(route));
|
||
auto &health = RouteHealthByDomain[key];
|
||
if (health.stalls || health.suppressions || health.suppressedUntil) {
|
||
LOG(("WSS Route: %1 restored (front carried %2 bytes)."
|
||
).arg(key
|
||
).arg(kCdnProofBytes));
|
||
}
|
||
// A front past the freeze lifts its suppression too: with everything
|
||
// suppressed the fronts carry the DC anyway, and a suppression left in
|
||
// place moved it into the tunnel once the tunnel's own expired.
|
||
health = RouteHealth();
|
||
}
|
||
|
||
void NoteCdnStalled(const WssRoute &route, qint64 received) {
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto key = NetworkKey(route.metered, HealthName(route));
|
||
auto &health = RouteHealthByDomain[key];
|
||
const auto now = crl::now();
|
||
if (health.suppressedUntil > now) {
|
||
return;
|
||
}
|
||
++health.stalls;
|
||
LOG(("WSS Route: %1 stall after %2 bytes, %3/%4."
|
||
).arg(key
|
||
).arg(received
|
||
).arg(health.stalls
|
||
).arg(kCdnStallsBeforeSuppress));
|
||
if (health.stalls >= kCdnStallsBeforeSuppress) {
|
||
health.stalls = 0;
|
||
SuppressLocked(route, health, now, u"stalls"_q);
|
||
}
|
||
}
|
||
|
||
// Every connection starts from this position in kCdnFronts, per network. A
|
||
// failure moves it on, so the next connection tries another front and edge;
|
||
// a working front is kept. A random start spreads ZaStoGram users over the
|
||
// whole catalog instead of all loading its first domain.
|
||
std::map<bool, int> CdnCursor;
|
||
|
||
// Called with RelayPreferencesMutex held.
|
||
[[nodiscard]] int CdnCursorLocked(bool metered) {
|
||
auto i = CdnCursor.find(metered);
|
||
if (i == end(CdnCursor)) {
|
||
i = CdnCursor.emplace(metered, base::RandomIndex(kCdnFrontCount)).first;
|
||
}
|
||
return i->second;
|
||
}
|
||
|
||
// Which of the two pinned addresses the fronts are dialled on: switches
|
||
// every time the cursor has gone round the whole catalog.
|
||
std::map<bool, int> CdnAddressRound;
|
||
|
||
void AdvanceCdn(const WssRoute &route) {
|
||
if (!IsCdn(route)) {
|
||
return;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
auto &cursor = CdnCursor[route.metered];
|
||
if (cursor == route.cdnSlot) {
|
||
cursor = (cursor + 1) % kCdnFrontCount;
|
||
if (!cursor) {
|
||
CdnAddressRound[route.metered] ^= 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
[[nodiscard]] QString DecodeCdnDomain(const char *encoded) {
|
||
// tg-ws-proxy's decoder: shift each letter back by the letter count.
|
||
auto name = QString::fromLatin1(encoded);
|
||
if (!name.endsWith(u".com"_q)) {
|
||
return name;
|
||
}
|
||
name.chop(4);
|
||
auto letters = 0;
|
||
for (const auto ch : name) {
|
||
letters += ch.isLetter() ? 1 : 0;
|
||
}
|
||
for (auto &ch : name) {
|
||
const auto c = ch.toLatin1();
|
||
if (c >= 'a' && c <= 'z') {
|
||
ch = QChar('a' + ((c - 'a') - letters % 26 + 26) % 26);
|
||
} else if (c >= 'A' && c <= 'Z') {
|
||
ch = QChar('A' + ((c - 'A') - letters % 26 + 26) % 26);
|
||
}
|
||
}
|
||
return name + u".co.uk"_q;
|
||
}
|
||
|
||
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"workers.dev"_q,
|
||
});
|
||
}
|
||
|
||
[[nodiscard]] QByteArray RandomBytes(int count) {
|
||
auto result = QByteArray(count, char(0));
|
||
bytes::set_random(bytes::make_detached_span(result));
|
||
return result;
|
||
}
|
||
|
||
// The ingress OfficialRoute dials: the one that last answered on this
|
||
// network, else the list order, with addresses that failed in the last two
|
||
// minutes moved back and suppressed ones skipped. With every address down it
|
||
// returns the first, whose suppression then sends the DC down the ladder.
|
||
[[nodiscard]] QString SelectRelayAddress(bool metered, int dcId) {
|
||
const auto &addresses = OfficialRelayIngresses(dcId);
|
||
if (addresses.isEmpty()) {
|
||
return QString();
|
||
}
|
||
auto order = QStringList();
|
||
{
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = RelayAddressStates.find(
|
||
RelayAddressStateKey(metered, dcId));
|
||
const auto preferred = (i != end(RelayAddressStates))
|
||
? i->second.preferred
|
||
: QString();
|
||
if (addresses.contains(preferred)) {
|
||
order.push_back(preferred);
|
||
}
|
||
for (const auto &address : addresses) {
|
||
if (address != preferred) {
|
||
order.push_back(address);
|
||
}
|
||
}
|
||
if (i != end(RelayAddressStates)) {
|
||
const auto now = crl::now();
|
||
std::stable_partition(order.begin(), order.end(), [&](
|
||
const QString &address) {
|
||
const auto failed = i->second.failedAt.find(address);
|
||
return (failed == end(i->second.failedAt))
|
||
|| (now - failed->second >= kRouteSuppressTtl);
|
||
});
|
||
}
|
||
}
|
||
for (const auto &address : order) {
|
||
auto relay = WssRoute();
|
||
relay.metered = metered;
|
||
relay.relayHost = address;
|
||
if (!RouteSuppressed(RelayAddressRoute(relay))) {
|
||
return address;
|
||
}
|
||
}
|
||
return order.front();
|
||
}
|
||
|
||
// A relay address failed. Returns the DC's other ingress that has not failed
|
||
// in the last two minutes, which new sockets dial from now on instead of the
|
||
// DNS name, or an empty string when none is left.
|
||
[[nodiscard]] QString MoveToNextRelayAddress(
|
||
const WssRoute &route,
|
||
bool viaFallback) {
|
||
if (!IsOfficialRelay(route) || viaFallback || !route.relayDcId) {
|
||
return QString();
|
||
}
|
||
const auto &addresses = OfficialRelayIngresses(route.relayDcId);
|
||
if (addresses.size() < 2) {
|
||
return QString();
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto now = crl::now();
|
||
auto &state = RelayAddressStates[
|
||
RelayAddressStateKey(route.metered, route.relayDcId)];
|
||
state.failedAt[route.relayHost] = now;
|
||
for (const auto &address : addresses) {
|
||
if (address == route.relayHost) {
|
||
continue;
|
||
}
|
||
const auto failed = state.failedAt.find(address);
|
||
if (failed != end(state.failedAt)
|
||
&& (now - failed->second < kRouteSuppressTtl)) {
|
||
continue;
|
||
}
|
||
if (state.preferred != address) {
|
||
state.preferred = address;
|
||
LOG(("WSS Route: dc %1 relay %2 failed, next: %3."
|
||
).arg(route.relayDcId
|
||
).arg(route.relayHost
|
||
).arg(address));
|
||
}
|
||
return address;
|
||
}
|
||
return QString();
|
||
}
|
||
|
||
void NoteRelayAddressWorked(const WssRoute &route) {
|
||
if (!IsOfficialRelay(route) || !route.relayDcId) {
|
||
return;
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
auto &state = RelayAddressStates[
|
||
RelayAddressStateKey(route.metered, route.relayDcId)];
|
||
state.preferred = route.relayHost;
|
||
state.failedAt.erase(route.relayHost);
|
||
}
|
||
|
||
[[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 metered = CurrentNetworkMetered.load();
|
||
const auto ingress = SelectRelayAddress(metered, positive);
|
||
if (ingress.isEmpty()) {
|
||
return std::nullopt; // CDN and unknown ids have no public web relay
|
||
}
|
||
auto route = WssRoute();
|
||
route.metered = metered;
|
||
route.relayHost = ingress;
|
||
route.relayDcId = positive;
|
||
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;
|
||
}
|
||
|
||
// The first worker not suppressed now, in a circle from the one this install
|
||
// starts with.
|
||
[[nodiscard]] std::optional<WssRoute> TunnelRoute() {
|
||
static const auto start = int(uchar(RandomBytes(1)[0])) % kTunnelHostCount;
|
||
for (auto step = 0; step != kTunnelHostCount; ++step) {
|
||
auto route = WssRoute();
|
||
route.metered = CurrentNetworkMetered.load();
|
||
route.relayHost = QString::fromLatin1(
|
||
kTunnelHosts[(start + step) % kTunnelHostCount]);
|
||
route.relayPort = 443;
|
||
route.domain = route.relayHost;
|
||
route.path = u"/apiws"_q;
|
||
route.tunnel = true;
|
||
if (!RouteSuppressed(route)) {
|
||
return route;
|
||
}
|
||
}
|
||
return std::nullopt;
|
||
}
|
||
|
||
[[nodiscard]] std::optional<WssRoute> CdnRoute(
|
||
int dcId,
|
||
bool ignoreSuppression = false) {
|
||
auto route = WssRoute();
|
||
route.metered = CurrentNetworkMetered.load();
|
||
const auto prefix = u"kws%1"_q.arg(dcId);
|
||
route.healthDomain = u"cdn-"_q + prefix;
|
||
if (!ignoreSuppression && RouteSuppressed(route)) {
|
||
return std::nullopt;
|
||
}
|
||
auto slot = 0;
|
||
auto address = 0;
|
||
{
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
slot = CdnCursorLocked(route.metered);
|
||
address = CdnAddressRound[route.metered];
|
||
}
|
||
const auto front = kCdnFronts[slot % kCdnFrontCount];
|
||
route.cdnSlot = slot;
|
||
route.cdnDcId = dcId;
|
||
// The fronts have no -1 media hosts: media rides kwsN too, as in Mirrly,
|
||
// with the plain DC in the obfuscation header (plainDcMarker). The pinned
|
||
// address is dialled first and the name once on the same socket; the
|
||
// next socket moves to the next front.
|
||
route.domain = prefix + u"."_q + DecodeCdnDomain(front);
|
||
route.relayHost = QString::fromLatin1(
|
||
kCdnFrontAddresses[slot % kCdnFrontCount][address]);
|
||
route.relayHostFallback = route.domain;
|
||
route.relayPort = 443;
|
||
route.path = u"/apiws"_q;
|
||
return route;
|
||
}
|
||
|
||
// Received bytes that prove a direct step, as kCdnProofBytes do a front.
|
||
constexpr auto kDirectProofBytes = qint64(32 * 1024);
|
||
|
||
// One health record per DC and network for direct TCP; media shares it.
|
||
[[nodiscard]] QString DirectHealthName(int dcId) {
|
||
return u"direct-dc%1"_q.arg((dcId < 0) ? -dcId : dcId);
|
||
}
|
||
|
||
// Direct TCP as the last guarded step. Networks that block the relays close
|
||
// direct TCP too, and the unguarded fallback kept DC2 "connecting" for
|
||
// minutes (Android logs (1) (8)): one failure pauses it like a suppressed
|
||
// route, so a blocked network pays one connect budget per pause, while one
|
||
// where only Cloudflare is closed still reaches the DC.
|
||
[[nodiscard]] std::optional<WssRoute> DirectRoute(int dcId) {
|
||
auto route = WssRoute();
|
||
route.metered = CurrentNetworkMetered.load();
|
||
route.direct = true;
|
||
route.domain = route.healthDomain = DirectHealthName(dcId);
|
||
if (RouteSuppressed(route)) {
|
||
return std::nullopt;
|
||
}
|
||
return route;
|
||
}
|
||
|
||
void NoteDirectStepClosed(
|
||
const WssRoute &route,
|
||
qint64 received,
|
||
const char *failure) {
|
||
const auto key = NetworkKey(route.metered, HealthName(route));
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
LoadRouteHealthLocked();
|
||
auto &health = RouteHealthByDomain[key];
|
||
const auto now = crl::now();
|
||
if (received >= kDirectProofBytes) {
|
||
const auto persisted = (health.suppressions != 0);
|
||
if (persisted) {
|
||
LOG(("WSS Route: %1 restored (proven).").arg(key));
|
||
}
|
||
health = RouteHealth();
|
||
if (persisted) {
|
||
SaveRouteHealthLocked();
|
||
}
|
||
return;
|
||
} else if (!failure || health.suppressedUntil > now) {
|
||
return;
|
||
}
|
||
health.lastFailureAt = now;
|
||
LOG(("WSS Route: %1 failed after %2 bytes (%3)."
|
||
).arg(key
|
||
).arg(received
|
||
).arg(QString::fromLatin1(failure)));
|
||
SuppressLocked(route, health, now, QString::fromLatin1(failure));
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void WssTrackNetwork() {
|
||
static auto tracked = false;
|
||
if (tracked) {
|
||
return;
|
||
}
|
||
tracked = true;
|
||
if (!QNetworkInformation::loadBackendByFeatures(
|
||
QNetworkInformation::Feature::Metered)) {
|
||
LOG(("WSS Route: network type unknown, one state for all networks."));
|
||
return;
|
||
}
|
||
const auto information = QNetworkInformation::instance();
|
||
const auto apply = [](bool metered) {
|
||
if (CurrentNetworkMetered.exchange(metered) != metered) {
|
||
LOG(("WSS Route: network %1."
|
||
).arg(metered ? u"metered"_q : u"unmetered"_q));
|
||
}
|
||
};
|
||
apply(information->isMetered());
|
||
QObject::connect(
|
||
information,
|
||
&QNetworkInformation::isMeteredChanged,
|
||
information,
|
||
apply);
|
||
}
|
||
|
||
std::optional<WssRoute> WssOfficialRoute(int16 protocolDcId) {
|
||
auto route = OfficialRoute(protocolDcId);
|
||
if (!route) {
|
||
const auto raw = int(protocolDcId);
|
||
if (raw != kNoRelayDcId && raw != -kNoRelayDcId) {
|
||
return std::nullopt;
|
||
}
|
||
if (auto front = CdnRoute(kNoRelayDcId)) {
|
||
return front;
|
||
}
|
||
if (auto tunnel = TunnelRoute()) {
|
||
return tunnel;
|
||
}
|
||
if (auto direct = DirectRoute(kNoRelayDcId)) {
|
||
return direct;
|
||
}
|
||
return CdnRoute(kNoRelayDcId, true);
|
||
}
|
||
if (RouteSuppressed(*route)
|
||
|| RouteSuppressed(RelayAddressRoute(*route))) {
|
||
// Релей датацентра недоступен: сначала фронты Cloudflare, потом
|
||
// туннель, потом прямой TCP, если он не на паузе. Если подавлено
|
||
// всё, остаются фронты: прямой TCP без охраны, к которому это
|
||
// раньше сваливалось, такие сети и закрывают.
|
||
const auto raw = int(protocolDcId);
|
||
const auto dcId = (raw < 0) ? -raw : raw;
|
||
if (auto front = CdnRoute(dcId)) {
|
||
return front;
|
||
}
|
||
if (auto tunnel = TunnelRoute()) {
|
||
return tunnel;
|
||
}
|
||
if (auto direct = DirectRoute(dcId)) {
|
||
return direct;
|
||
}
|
||
static auto lastFloorLog = std::atomic<crl::time>(0);
|
||
const auto now = crl::now();
|
||
if (now - lastFloorLog.load() > 10 * crl::time(1000)) {
|
||
lastFloorLog = now;
|
||
LOG(("WSS Route: dc %1 has every route suppressed, direct TCP "
|
||
"too, using the Cloudflare fronts anyway.").arg(raw));
|
||
}
|
||
return CdnRoute(dcId, true);
|
||
}
|
||
return route;
|
||
}
|
||
|
||
bool WssRelayUnproven(int16 protocolDcId) {
|
||
const auto route = OfficialRoute(protocolDcId);
|
||
if (!route) {
|
||
return false;
|
||
}
|
||
const auto address = RelayAddressRoute(*route);
|
||
if (RouteSuppressed(*route) || RouteSuppressed(address)) {
|
||
return false; // WssOfficialRoute already gives the fronts.
|
||
}
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto failing = [&](const WssRoute &value) {
|
||
const auto i = RouteHealthByDomain.find(
|
||
NetworkKey(value.metered, HealthName(value)));
|
||
return (i != end(RouteHealthByDomain))
|
||
&& (i->second.consecutiveFailures > 0);
|
||
};
|
||
const auto i = RouteHealthByDomain.find(
|
||
NetworkKey(route->metered, HealthName(*route)));
|
||
const auto answered = (i != end(RouteHealthByDomain))
|
||
&& i->second.answered;
|
||
return !answered || failing(*route) || failing(address);
|
||
}
|
||
|
||
std::optional<WssRoute> WssFrontRoute(int16 protocolDcId) {
|
||
if (!OfficialRoute(protocolDcId)) {
|
||
return std::nullopt;
|
||
}
|
||
const auto raw = int(protocolDcId);
|
||
return CdnRoute((raw < 0) ? -raw : raw);
|
||
}
|
||
|
||
bool WssMediaTunneled(int dcId) {
|
||
// Only the tunnel freezes by design; media on a Cloudflare front uses
|
||
// ordinary parts.
|
||
const auto route = WssOfficialRoute(int16(-dcId));
|
||
return route && route->tunnel;
|
||
}
|
||
|
||
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);
|
||
{
|
||
QMutexLocker lock(&RelayPreferencesMutex);
|
||
const auto i = RouteHealthByDomain.find(
|
||
NetworkKey(route.metered, 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;
|
||
}
|
||
|
||
WssDirectStepSocket::WssDirectStepSocket(
|
||
not_null<RuntimeEnvironment*> runtime,
|
||
not_null<QThread*> thread,
|
||
const QNetworkProxy &proxy,
|
||
bool protocolForFiles,
|
||
WssRoute route)
|
||
: AbstractSocket(runtime, thread)
|
||
, _tcp(std::make_unique<TcpSocket>(runtime, thread, proxy, protocolForFiles))
|
||
, _route(std::move(route)) {
|
||
_tcp->connected(
|
||
) | rpl::on_next([=] {
|
||
_connected.fire({});
|
||
}, _lifetime);
|
||
_tcp->disconnected(
|
||
) | rpl::on_next([=] {
|
||
judge("closed_short");
|
||
_disconnected.fire({});
|
||
}, _lifetime);
|
||
_tcp->readyRead(
|
||
) | rpl::on_next([=] {
|
||
_readyRead.fire({});
|
||
}, _lifetime);
|
||
_tcp->error(
|
||
) | rpl::on_next([=](int errorCode) {
|
||
judge("socket_error");
|
||
_error.fire_copy(errorCode);
|
||
}, _lifetime);
|
||
_tcp->syncTimeRequests(
|
||
) | rpl::on_next([=] {
|
||
_syncTimeRequests.fire({});
|
||
}, _lifetime);
|
||
_tcp->progress(
|
||
) | rpl::on_next([=](HandshakePhase phase) {
|
||
_progress.fire_copy(phase);
|
||
}, _lifetime);
|
||
}
|
||
|
||
// A socket destroyed without a verdict lost a race to a sibling or was
|
||
// dropped by its session: no evidence about the path either way.
|
||
WssDirectStepSocket::~WssDirectStepSocket() = default;
|
||
|
||
void WssDirectStepSocket::connectToHost(const QString &address, int port) {
|
||
_ipv6 = address.contains(':');
|
||
_tcp->setDebugId(_debugId);
|
||
LOG(("WSS Route: %1 direct step to %2:%3."
|
||
).arg(NetworkKey(_route.metered, HealthName(_route))
|
||
).arg(address
|
||
).arg(port));
|
||
_tcp->connectToHost(address, port);
|
||
}
|
||
|
||
bool WssDirectStepSocket::isGoodStartNonce(bytes::const_span nonce) {
|
||
return _tcp->isGoodStartNonce(nonce);
|
||
}
|
||
|
||
void WssDirectStepSocket::timedOut() {
|
||
judge(!_tcp->isConnected()
|
||
? "tcp_timeout"
|
||
: !_bytesReceived
|
||
? "no_answer"
|
||
: "stalled");
|
||
_tcp->timedOut();
|
||
}
|
||
|
||
bool WssDirectStepSocket::isConnected() {
|
||
return _tcp->isConnected();
|
||
}
|
||
|
||
bool WssDirectStepSocket::hasBytesAvailable() {
|
||
return _tcp->hasBytesAvailable();
|
||
}
|
||
|
||
int64 WssDirectStepSocket::read(bytes::span buffer) {
|
||
const auto result = _tcp->read(buffer);
|
||
if (result > 0) {
|
||
_bytesReceived += result;
|
||
if (_bytesReceived >= kDirectProofBytes) {
|
||
judge(nullptr);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
void WssDirectStepSocket::write(
|
||
bytes::const_span prefix,
|
||
bytes::const_span buffer) {
|
||
_tcp->write(prefix, buffer);
|
||
}
|
||
|
||
int32 WssDirectStepSocket::debugState() {
|
||
return _tcp->debugState();
|
||
}
|
||
|
||
QString WssDirectStepSocket::debugPostfix() const {
|
||
return u"_direct"_q;
|
||
}
|
||
|
||
// One verdict per socket. Only IPv4 failures count: with IPv6 enabled but
|
||
// absent, every IPv6 sibling fails and would pause a working IPv4 path.
|
||
void WssDirectStepSocket::judge(const char *failure) {
|
||
if (_judged
|
||
|| (_bytesReceived < kDirectProofBytes && (!failure || _ipv6))) {
|
||
return;
|
||
}
|
||
_judged = true;
|
||
NoteDirectStepClosed(
|
||
_route,
|
||
_bytesReceived,
|
||
(_bytesReceived < kDirectProofBytes) ? failure : nullptr);
|
||
}
|
||
|
||
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
|
||
: IsCdn(_route)
|
||
? u"WSSFront"_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 upgrade_retries=%9"_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)
|
||
.arg(_upgradeRetries),
|
||
.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::plainDcMarker() const {
|
||
return IsCdn(_route);
|
||
}
|
||
|
||
bool WssSocket::outputDrained() const {
|
||
// A photo going out slowly gets no answer until its part is complete, so
|
||
// silence while bytes still wait to be written is an upload in progress,
|
||
// not a frozen front.
|
||
return !_socket.bytesToWrite() && !_socket.encryptedBytesToWrite();
|
||
}
|
||
|
||
void WssSocket::noteFrontFailed() {
|
||
if (!IsCdn(_route) || _frontFailureNoted) {
|
||
return;
|
||
}
|
||
_frontFailureNoted = true;
|
||
AdvanceCdn(_route);
|
||
if (!_tcpConnected
|
||
&& (TcpRecentlyConnected(_route, _currentHost)
|
||
|| TcpRecentlyConnected(_route, kAnyCdnHost))) {
|
||
// Another front reached TCP just now: the provider dropped this
|
||
// flow's SYN, the fronts are fine.
|
||
return;
|
||
}
|
||
// A front refused at any stage, 503 included, is counted: otherwise a
|
||
// Flowseal-wide outage would never reach the tunnel.
|
||
NoteRouteUnreachable(_route);
|
||
}
|
||
|
||
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 (IsCdn(_route)) {
|
||
if (!_upgraded || !_bytesReceived) {
|
||
noteFrontFailed();
|
||
} else if (_bytesReceived < kCdnProofBytes && outputDrained()) {
|
||
// The front answered and then went quiet with nothing left to
|
||
// send: the freeze the tunnel suffers. Counted apart from
|
||
// refusals, and only a session past kCdnProofBytes clears it.
|
||
AdvanceCdn(_route);
|
||
NoteCdnStalled(_route, _bytesReceived);
|
||
}
|
||
return;
|
||
}
|
||
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(_route, _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
|
||
&& MoveToNextRelayAddress(_route, _usedFallback).isEmpty()) {
|
||
NoteRelayAttemptFailed(_route, _usedFallback);
|
||
}
|
||
if (!_upgraded && !_tcpConnected) {
|
||
// Не дошли даже до установленного TCP: адрес релея недоступен, а не
|
||
// протокол сломан.
|
||
if (IsOfficialRelay(_route) && !_usedFallback) {
|
||
NoteRouteUnreachable(RelayAddressRoute(_route));
|
||
}
|
||
NoteRouteUnreachable(_route);
|
||
SuppressRelayAddressWithDomain(_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 && !_addressMoved) {
|
||
// DC2/DC4 have a second ingress: try it before the DNS name, which
|
||
// depends on the resolver and may land on the same blocked address.
|
||
const auto next = MoveToNextRelayAddress(_route, _usedFallback);
|
||
if (!next.isEmpty()) {
|
||
_addressMoved = true;
|
||
_route.relayHost = next;
|
||
_incoming = QByteArray();
|
||
_phase = HandshakePhase::None;
|
||
_socket.abort();
|
||
connectToRelayHost();
|
||
return;
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
if (!_upgraded) {
|
||
noteFrontFailed();
|
||
}
|
||
if (!_upgraded
|
||
&& !_tcpConnected
|
||
&& !_route.tunnel
|
||
&& !IsCdn(_route)
|
||
&& !TcpRecentlyConnected(_route, _currentHost)) {
|
||
// Refused outright (a reset, a reject, no such host) instead of
|
||
// staying silent: the session never waits out a budget for such a
|
||
// socket, so timedOut() never counted it, the relay was never
|
||
// suppressed and the fronts were never tried (VM test 02.10: two
|
||
// minutes of retries against a rejecting relay).
|
||
if (IsOfficialRelay(_route) && _hostFlipped) {
|
||
NoteRouteUnreachable(RelayAddressRoute(_route));
|
||
}
|
||
NoteRouteUnreachable(_route);
|
||
SuppressRelayAddressWithDomain(_route);
|
||
}
|
||
logError(errorCode, _socket.errorString());
|
||
_error.fire_copy(errorCode);
|
||
}
|
||
|
||
void WssSocket::onTcpConnected() {
|
||
_tcpConnected = true;
|
||
NoteTcpConnected(_route, _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")) {
|
||
if (retryRefusedUpgrade(header)) {
|
||
return false;
|
||
}
|
||
noteFrontFailed();
|
||
if (_route.tunnel && header.startsWith("HTTP/1.1 429")) {
|
||
NoteTunnelQuotaExhausted(_route);
|
||
}
|
||
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 && !IsCdn(_route)) {
|
||
// The Cloudflare tunnel upgrades fine and then freezes after ~16 KB
|
||
// on a throttled network; it proves itself in parseFrames instead,
|
||
// and so do the fronts, which also upgrade for silent paths.
|
||
NoteRouteReachable(_route);
|
||
if (IsOfficialRelay(_route) && !_usedFallback) {
|
||
// Only an answer through the hardcoded address clears it; the
|
||
// DNS fallback reaches another one.
|
||
NoteRouteReachable(RelayAddressRoute(_route));
|
||
NoteRelayAddressWorked(_route);
|
||
}
|
||
}
|
||
NoteRelayUpgraded(_route, _usedFallback);
|
||
_phase = HandshakePhase::ServerHelloOk;
|
||
connectionProgress(_phase);
|
||
_connected.fire({});
|
||
return true;
|
||
}
|
||
|
||
bool WssSocket::retryRefusedUpgrade(const QByteArray &header) {
|
||
// A front connection is either served at once or answers 503 to every
|
||
// upgrade; an empty keep-alive 503 leaves the connection clean for the
|
||
// next request, which costs ~25 ms against a new TCP+TLS dial.
|
||
if (!IsCdn(_route)
|
||
|| _upgradeRetries >= kCdnUpgradeRetries
|
||
|| !_incoming.isEmpty()
|
||
|| !(header.startsWith("HTTP/1.1 503")
|
||
|| header.startsWith("HTTP/1.1 429"))) {
|
||
return false;
|
||
}
|
||
const auto lowered = header.toLower() + "\r\n";
|
||
if (!lowered.contains("\r\ncontent-length: 0\r\n")
|
||
|| lowered.contains("connection: close")) {
|
||
return false;
|
||
}
|
||
++_upgradeRetries;
|
||
sendHttpUpgrade();
|
||
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);
|
||
}
|
||
if (IsCdn(_route) && !_frontAnswered) {
|
||
_frontAnswered = true;
|
||
NoteRouteReachable(_route);
|
||
}
|
||
if (IsCdn(_route)
|
||
&& !_frontProven
|
||
&& _bytesReceived >= kCdnProofBytes) {
|
||
_frontProven = true;
|
||
NoteCdnProven(_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
|