518 lines
15 KiB
C++
518 lines
15 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 "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>
|
|
|
|
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());
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] QByteArray RandomBytes(int count) {
|
|
auto result = QByteArray(count, char(0));
|
|
bytes::set_random(bytes::make_detached_span(result));
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::optional<WssRoute> WssOfficialRoute(int16 protocolDcId, bool media) {
|
|
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
|
|
} else if (positive != 2 && positive != 4) {
|
|
return std::nullopt; // web sockets exist only for DC2 / DC4
|
|
}
|
|
auto route = WssRoute();
|
|
route.relayHost = u"149.154.167.220"_q;
|
|
route.relayPort = 443;
|
|
route.path = u"/apiws"_q;
|
|
const auto name = (positive == 4) ? u"kws4"_q : u"kws2"_q;
|
|
route.domain = media
|
|
? (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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
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,
|
|
&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); }));
|
|
}
|
|
|
|
void WssSocket::connectToHost(const QString &address, int port) {
|
|
Q_UNUSED(address);
|
|
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.
|
|
_usedFallback = PreferRelayFallback(_route);
|
|
connectToRelayHost();
|
|
}
|
|
|
|
void WssSocket::connectToRelayHost() {
|
|
const auto host = _usedFallback ? _route.relayHostFallback : _route.relayHost;
|
|
_socket.setPeerVerifyName(_route.domain);
|
|
_socket.connectToHostEncrypted(
|
|
host,
|
|
quint16(_route.relayPort),
|
|
_route.domain);
|
|
}
|
|
|
|
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() {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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());
|
|
|
|
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 DC2/DC4 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::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;
|
|
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) {
|
|
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;
|
|
connectionProgress(_phase);
|
|
}
|
|
_readyRead.fire({});
|
|
}
|
|
}
|
|
|
|
void WssSocket::sendFrame(quint8 opcode, bytes::const_span data) {
|
|
const auto size = int(data.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
|