Initial WEB proxy implementation.

This commit is contained in:
John Preston
2026-08-09 22:34:23 +04:00
parent 0178322588
commit 96d63afc3f
23 changed files with 3179 additions and 38 deletions

View File

@@ -1599,6 +1599,8 @@ PRIVATE
mtproto/core_types.h
mtproto/dedicated_file_loader.cpp
mtproto/dedicated_file_loader.h
mtproto/details/mtproto_web_proxy_socket.cpp
mtproto/details/mtproto_web_proxy_socket.h
mtproto/facade.cpp
mtproto/facade.h
mtproto/mtp_instance.cpp
@@ -1613,6 +1615,10 @@ PRIVATE
mtproto/special_config_request.cpp
mtproto/special_config_request.h
mtproto/type_utils.h
mtproto/web_proxy/web_proxy_frame.cpp
mtproto/web_proxy/web_proxy_frame.h
mtproto/web_proxy/web_proxy_transport.cpp
mtproto/web_proxy/web_proxy_transport.h
overview/overview_checkbox.cpp
overview/overview_checkbox.h
overview/overview_layout.cpp

View File

@@ -1291,17 +1291,21 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
"lng_proxy_online" = "online";
"lng_proxy_checking" = "checking…";
"lng_proxy_connecting" = "connecting…";
"lng_proxy_web_waiting" = "waiting for browser…";
"lng_proxy_available" = "available (ping: {ping} ms)";
"lng_proxy_unavailable" = "not available";
"lng_proxy_edit" = "Edit proxy";
"lng_proxy_menu_edit" = "Edit";
"lng_proxy_menu_delete" = "Delete";
"lng_proxy_menu_restore" = "Restore";
"lng_proxy_web_open" = "Open browser";
"lng_proxy_edit_share" = "Share";
"lng_proxy_edit_share_qr_box_title" = "Share proxy with QR code";
"lng_proxy_edit_share_list_button" = "Share Proxy List";
"lng_proxy_edit_share_list_toast" = "Proxy List copied to clipboard.";
"lng_proxy_address_label" = "Socket address";
"lng_proxy_web_host_label" = "Web proxy hostname";
"lng_proxy_web_host_ph" = "proxy.example.com";
"lng_proxy_credentials_optional" = "Credentials (optional)";
"lng_proxy_credentials" = "Credentials";
"lng_proxy_description" = "Your saved proxy list will be here.";

View File

@@ -21,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "mtproto/facade.h"
#include "mtproto/mtproto_config.h"
#include "mtproto/proxy_check.h"
#include "mtproto/web_proxy/web_proxy_transport.h"
#include "qr/qr_generate.h"
#include "settings/settings_common.h"
#include "storage/localstorage.h"
@@ -111,7 +112,8 @@ using ProxyData = MTP::ProxyData;
case Type::Socks5: return u"socks"_q;
case Type::Mtproto: return u"proxy"_q;
case Type::None:
case Type::Http: return QString();
case Type::Http:
case Type::Web: return QString();
}
Unexpected("Proxy type in ProxyDataToQueryPath.");
}();
@@ -567,6 +569,7 @@ public:
rpl::producer<> editClicks() const;
rpl::producer<> shareClicks() const;
rpl::producer<> showQrClicks() const;
rpl::producer<> openBrowserClicks() const;
protected:
int resizeGetHeight(int newWidth) override;
@@ -589,6 +592,7 @@ private:
rpl::event_stream<> _editClicks;
rpl::event_stream<> _shareClicks;
rpl::event_stream<> _showQrClicks;
rpl::event_stream<> _openBrowserClicks;
base::unique_qptr<Ui::DropdownMenu> _menu;
bool _set = false;
@@ -663,7 +667,11 @@ private:
void prepare() override;
void setInnerFocus() override {
_host->setFocusFast();
if (_type->current() == Type::Web) {
_webHost->setFocusFast();
} else {
_host->setFocusFast();
}
}
void refreshButtons();
@@ -673,6 +681,7 @@ private:
void setupControls(const ProxyData &data);
void setupTypes();
void setupSocketAddress(const ProxyData &data);
void setupWebAddress(const ProxyData &data);
void setupCredentials(const ProxyData &data);
void setupMtprotoCredentials(const ProxyData &data);
@@ -694,7 +703,10 @@ private:
QPointer<Ui::InputField> _user;
QPointer<Ui::PasswordInput> _password;
QPointer<Base64UrlInput> _secret;
QPointer<Ui::InputField> _webHost;
QPointer<Ui::SlideWrap<Ui::VerticalLayout>> _socketAddress;
QPointer<Ui::SlideWrap<Ui::VerticalLayout>> _webAddress;
QPointer<Ui::SlideWrap<Ui::VerticalLayout>> _credentials;
QPointer<Ui::SlideWrap<Ui::VerticalLayout>> _mtprotoCredentials;
@@ -726,6 +738,10 @@ rpl::producer<> ProxyRow::showQrClicks() const {
return _showQrClicks.events();
}
rpl::producer<> ProxyRow::openBrowserClicks() const {
return _openBrowserClicks.events();
}
void ProxyRow::setupControls(View &&view) {
updateFields(std::move(view));
_toggled.stop();
@@ -747,7 +763,9 @@ void ProxyRow::updateFields(View &&view) {
st::defaultRadio.duration);
}
_view = std::move(view);
const auto endpoint = _view.host + ':' + QString::number(_view.port);
const auto endpoint = _view.web
? _view.host
: _view.host + ':' + QString::number(_view.port);
_title.setMarkedText(
st::proxyRowTitleStyle,
TextWithEntities()
@@ -845,6 +863,8 @@ void ProxyRow::paintEvent(QPaintEvent *e) {
return st::proxyRowStatusFgOnline;
case State::Unavailable:
return st::proxyRowStatusFgOffline;
case State::WaitingForBrowser:
return st::proxyRowStatusFg;
case State::Available:
return st::proxyRowStatusFgAvailable;
default:
@@ -866,6 +886,8 @@ void ProxyRow::paintEvent(QPaintEvent *e) {
return tr::lng_proxy_online(tr::now);
case State::Unavailable:
return tr::lng_proxy_unavailable(tr::now);
case State::WaitingForBrowser:
return tr::lng_proxy_web_waiting(tr::now);
}
Unexpected("State in ProxyRow::paintEvent.");
}();
@@ -966,6 +988,11 @@ void ProxyRow::showMenu() {
addAction(tr::lng_proxy_menu_edit(tr::now), [=] {
_editClicks.fire({});
}, &st::menuIconEdit);
if (_view.canOpenBrowser) {
addAction(tr::lng_proxy_web_open(tr::now), [=] {
_openBrowserClicks.fire({});
}, &st::menuIconLink);
}
if (_view.supportsShare) {
addAction(tr::lng_proxy_edit_share(tr::now), [=] {
_shareClicks.fire({});
@@ -1403,6 +1430,11 @@ void ProxiesBox::setupButtons(int id, not_null<ProxyRow*> button) {
_controller->shareItem(id, qr);
}, button->lifetime());
button->openBrowserClicks(
) | rpl::on_next([=] {
_controller->openBrowser(id);
}, button->lifetime());
button->clicks(
) | rpl::on_next([=] {
_controller->applyItem(id);
@@ -1451,7 +1483,10 @@ void ProxyBox::prepare() {
}, _port->lifetime());
const auto submit = [=] {
if (_host->hasFocus()
if (_webHost->hasFocus()
&& !_webHost->getLastText().trimmed().isEmpty()) {
_secret->setFocus();
} else if (_host->hasFocus()
&& !_host->getLastText().trimmed().isEmpty()) {
_port->setFocus();
} else if (_port->hasFocus()
@@ -1471,6 +1506,8 @@ void ProxyBox::prepare() {
connect(_port.data(), &Ui::MaskedInputField::submitted, submit);
_user->submits(
) | rpl::on_next(submit, _user->lifetime());
_webHost->submits(
) | rpl::on_next(submit, _webHost->lifetime());
connect(_password.data(), &Ui::MaskedInputField::submitted, submit);
connect(_secret.data(), &Ui::MaskedInputField::submitted, submit);
@@ -1506,25 +1543,38 @@ void ProxyBox::share() {
ProxyData ProxyBox::collectData() {
auto result = ProxyData();
result.type = _type->current();
result.host = _host->getLastText().trimmed();
result.port = _port->getLastText().trimmed().toInt();
result.user = (result.type == Type::Mtproto)
const auto web = (result.type == Type::Web);
result.host = web
? MTP::NormalizeWebProxyHost(_webHost->getLastText())
: _host->getLastText().trimmed();
result.port = web
? 443
: _port->getLastText().trimmed().toInt();
result.user = (result.type == Type::Mtproto || web)
? QString()
: _user->getLastText();
result.password = (result.type == Type::Mtproto)
result.password = (result.type == Type::Mtproto || web)
? _secret->getLastText()
: _password->getLastText();
if (result.host.isEmpty()) {
_host->showError();
} else if (!result.port) {
if (web) {
_webHost->showError();
} else {
_host->showError();
}
} else if (!web && !result.port) {
_port->showError();
} else if ((result.type == Type::Http || result.type == Type::Socks5)
&& !result.password.isEmpty() && result.user.isEmpty()) {
_user->showError();
} else if (result.type == Type::Mtproto && !result.valid()) {
} else if ((result.type == Type::Mtproto || web) && !result.valid()) {
_secret->showError();
} else if (!result) {
_host->showError();
if (web) {
_webHost->showError();
} else {
_host->showError();
}
} else {
return result;
}
@@ -1533,6 +1583,7 @@ ProxyData ProxyBox::collectData() {
void ProxyBox::setupTypes() {
const auto types = std::vector<std::pair<Type, QString>>{
{ Type::Web, u"WEB"_q },
{ Type::Mtproto, u"MTPROTO"_q },
{ Type::Socks5, u"SOCKS5"_q },
{ Type::Http, u"HTTP"_q },
@@ -1558,10 +1609,15 @@ void ProxyBox::setupTypes() {
}
void ProxyBox::setupSocketAddress(const ProxyData &data) {
addLabel(_content, tr::lng_proxy_address_label(tr::now));
const auto address = _content->add(
object_ptr<Ui::FixedHeightWidget>(
_socketAddress = _content->add(
object_ptr<Ui::SlideWrap<Ui::VerticalLayout>>(
_content,
object_ptr<Ui::VerticalLayout>(_content)));
const auto content = _socketAddress->entity();
addLabel(content, tr::lng_proxy_address_label(tr::now));
const auto address = content->add(
object_ptr<Ui::FixedHeightWidget>(
content,
st::connectionHostInputField.heightMin),
st::proxyEditInputPadding);
_host = Ui::CreateChild<HostInput>(
@@ -1585,6 +1641,22 @@ void ProxyBox::setupSocketAddress(const ProxyData &data) {
}, address->lifetime());
}
void ProxyBox::setupWebAddress(const ProxyData &data) {
_webAddress = _content->add(
object_ptr<Ui::SlideWrap<Ui::VerticalLayout>>(
_content,
object_ptr<Ui::VerticalLayout>(_content)));
const auto content = _webAddress->entity();
addLabel(content, tr::lng_proxy_web_host_label(tr::now));
_webHost = content->add(
object_ptr<Ui::InputField>(
content,
st::connectionUserInputField,
tr::lng_proxy_web_host_ph(),
(data.type == Type::Web) ? data.host : QString()),
st::proxyEditInputPadding);
}
void ProxyBox::setupCredentials(const ProxyData &data) {
_credentials = _content->add(
object_ptr<Ui::SlideWrap<Ui::VerticalLayout>>(
@@ -1605,7 +1677,9 @@ void ProxyBox::setupCredentials(const ProxyData &data) {
passwordWrap.data(),
st::connectionPasswordInputField,
tr::lng_connection_password_ph(),
(data.type == Type::Mtproto) ? QString() : data.password);
(data.type == Type::Mtproto || data.type == Type::Web)
? QString()
: data.password);
_password->move(0, 0);
_password->heightValue(
) | rpl::on_next([=, wrap = passwordWrap.data()](int height) {
@@ -1631,7 +1705,9 @@ void ProxyBox::setupMtprotoCredentials(const ProxyData &data) {
secretWrap.data(),
st::connectionUserInputField,
tr::lng_connection_proxy_secret_ph(),
(data.type == Type::Mtproto) ? data.password : QString());
(data.type == Type::Mtproto || data.type == Type::Web)
? data.password
: QString());
_secret->move(0, 0);
_secret->heightValue(
) | rpl::on_next([=, wrap = secretWrap.data()](int height) {
@@ -1655,13 +1731,17 @@ void ProxyBox::setupControls(const ProxyData &data) {
setupTypes();
setupSocketAddress(data);
setupWebAddress(data);
setupCredentials(data);
setupMtprotoCredentials(data);
const auto handleType = [=](Type type) {
const auto web = (type == Type::Web);
const auto credentialsShown
= (type == Type::Http || type == Type::Socks5);
const auto mtprotoShown = (type == Type::Mtproto);
const auto mtprotoShown = (type == Type::Mtproto || web);
_socketAddress->toggle(!web, anim::type::instant);
_webAddress->toggle(web, anim::type::instant);
_credentials->toggle(credentialsShown, anim::type::instant);
_mtprotoCredentials->toggle(mtprotoShown, anim::type::instant);
_aboutSponsored->toggle(mtprotoShown, anim::type::instant);
@@ -1672,6 +1752,7 @@ void ProxyBox::setupControls(const ProxyData &data) {
_password->setFocusPolicy(credentialsPolicy);
_secret->setFocusPolicy(
mtprotoShown ? Qt::StrongFocus : Qt::NoFocus);
_webHost->setFocusPolicy(web ? Qt::StrongFocus : Qt::NoFocus);
};
_type->setChangedCallback([=](Type type) {
handleType(type);
@@ -1715,6 +1796,14 @@ ProxiesBoxController::ProxiesBoxController(not_null<Main::Account*> account)
}
}, _lifetime);
MTP::WebProxy::Transport::StateChanges(
) | rpl::on_next([=](const MTP::WebProxy::Transport::StateChange &change) {
const auto i = findByProxy(change.proxy);
if (i != end(_list)) {
updateView(*i);
}
}, _lifetime);
for (auto &item : _list) {
refreshChecker(item);
}
@@ -2011,6 +2100,13 @@ auto ProxiesBoxController::proxySettingsValue() const
}
void ProxiesBoxController::refreshChecker(Item &item) {
if (item.data.type == Type::Web) {
MTP::ResetProxyCheckers(item.checker, item.checkerv6);
item.state = ItemState::Unavailable;
item.ping = 0;
updateView(item);
return;
}
item.state = ItemState::Checking;
const auto id = item.id;
MTP::StartProxyCheck(
@@ -2151,6 +2247,15 @@ void ProxiesBoxController::applyItem(int id) {
updateView(*item);
}
void ProxiesBoxController::openBrowser(int id) {
const auto item = findById(id);
if (_settings.isEnabled()
&& _settings.selected() == item->data
&& item->data.type == Type::Web) {
MTP::WebProxy::Transport::OpenBrowser(item->data);
}
}
void ProxiesBoxController::setDeleted(int id, bool deleted) {
auto item = findById(id);
item->deleted = deleted;
@@ -2373,12 +2478,26 @@ void ProxiesBoxController::updateView(const Item &item) {
case Type::Http: return u"HTTP"_q;
case Type::Socks5: return u"SOCKS5"_q;
case Type::Mtproto: return u"MTPROTO"_q;
case Type::Web: return u"WEB"_q;
}
Unexpected("Proxy type in ProxiesBoxController::updateView.");
}();
const auto state = [&] {
if (!selected || !_settings.isEnabled()) {
return item.state;
} else if (item.data.type == Type::Web) {
switch (MTP::WebProxy::Transport::CurrentState(item.data)) {
case MTP::WebProxy::Transport::State::WaitingForBrowser:
return ItemState::WaitingForBrowser;
case MTP::WebProxy::Transport::State::Failed:
return ItemState::Unavailable;
case MTP::WebProxy::Transport::State::Connected:
return ItemState::Online;
case MTP::WebProxy::Transport::State::Idle:
case MTP::WebProxy::Transport::State::Connecting:
return ItemState::Connecting;
}
Unexpected("Web proxy transport state.");
} else if (_account->mtp().dcstate() == MTP::ConnectedState) {
return ItemState::Online;
}
@@ -2396,6 +2515,11 @@ void ProxiesBoxController::updateView(const Item &item) {
deleted,
!deleted && supportsShare,
supportsCalls,
item.data.type == Type::Web,
!deleted
&& selected
&& _settings.isEnabled()
&& item.data.type == Type::Web,
state,
});
}

View File

@@ -61,7 +61,8 @@ public:
Online,
Checking,
Available,
Unavailable
Unavailable,
WaitingForBrowser,
};
struct ItemView {
int id = 0;
@@ -73,6 +74,8 @@ public:
bool deleted = false;
bool supportsShare = false;
bool supportsCalls = false;
bool web = false;
bool canOpenBrowser = false;
ItemState state = ItemState::Checking;
};
@@ -83,6 +86,7 @@ public:
void shareItem(int id, bool qr);
void shareItems();
void applyItem(int id);
void openBrowser(int id);
object_ptr<Ui::BoxContent> editItemBox(int id);
object_ptr<Ui::BoxContent> addNewItemBox();
bool setProxySettings(ProxyData::Settings value);

View File

@@ -64,6 +64,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "media/view/media_view_open_common.h"
#include "mtproto/mtproto_dc_options.h"
#include "mtproto/mtproto_config.h"
#include "mtproto/web_proxy/web_proxy_transport.h"
#include "media/audio/media_audio_track.h"
#include "media/player/media_player_instance.h"
#include "media/player/media_player_float.h"
@@ -247,6 +248,7 @@ Application::~Application() {
_private->proxyRotation = nullptr;
_domain->finish();
MTP::WebProxy::Transport::Shutdown();
Local::finish();
@@ -1871,6 +1873,15 @@ void Application::postponeCall(FnMut<void()> &&callable) {
}
void Application::refreshGlobalProxy() {
const auto &proxySettings = settings().proxy();
const auto proxy = proxySettings.isEnabled()
? proxySettings.selected()
: MTP::ProxyData();
if (proxy.type == MTP::ProxyData::Type::Web && proxy.valid()) {
MTP::WebProxy::Transport::Activate(proxy);
} else {
MTP::WebProxy::Transport::Deactivate();
}
Sandbox::Instance().refreshGlobalProxy();
}

View File

@@ -52,8 +52,9 @@ namespace {
case 1: return MTP::ProxyData::Type::Socks5;
case 2: return MTP::ProxyData::Type::Http;
case 3: return MTP::ProxyData::Type::Mtproto;
case 4: return MTP::ProxyData::Type::Web;
}
Unexpected("Bad type in DeserializeProxyData");
return MTP::ProxyData::Type::None;
}();
return proxy;
}
@@ -74,6 +75,7 @@ namespace {
case MTP::ProxyData::Type::Socks5: return 1;
case MTP::ProxyData::Type::Http: return 2;
case MTP::ProxyData::Type::Mtproto: return 3;
case MTP::ProxyData::Type::Web: return 4;
}
Unexpected("Bad type in SerializeProxyData");
}();

View File

@@ -153,16 +153,23 @@ void ProxyRotationManager::pruneRemovedEntries() {
void ProxyRotationManager::updateProbeOrder() {
const auto &settings = App().settings().proxy();
const auto currentIndex = settings.indexInList(settings.selected());
const auto canCheck = [&](int index) {
return index >= 0
&& index < int(settings.list().size())
&& settings.list()[index].type != MTP::ProxyData::Type::Web;
};
_probeOrder.clear();
_probeOrder.reserve(settings.list().size());
for (const auto index : settings.proxyRotationPreferredIndices()) {
if (index == currentIndex) {
if (index == currentIndex || !canCheck(index)) {
continue;
}
_probeOrder.push_back(index);
}
for (auto i = 0, count = int(settings.list().size()); i != count; ++i) {
if (i == currentIndex || ranges::contains(_probeOrder, i)) {
if (i == currentIndex
|| !canCheck(i)
|| ranges::contains(_probeOrder, i)) {
continue;
}
_probeOrder.push_back(i);

View File

@@ -76,7 +76,8 @@ void PromoSuggestions::refreshTopPromotion() {
return {};
}
const auto &proxy = Core::App().settings().proxy().selected();
if (proxy.type != MTP::ProxyData::Type::Mtproto) {
if (proxy.type != MTP::ProxyData::Type::Mtproto
&& proxy.type != MTP::ProxyData::Type::Web) {
return {};
}
return { proxy.host, proxy.port };

View File

@@ -97,7 +97,8 @@ void Account::watchProxyChanges() {
Core::App().proxyChanges(
) | rpl::on_next([=](const ProxyChange &change) {
const auto key = [&](const MTP::ProxyData &proxy) {
return (proxy.type == MTP::ProxyData::Type::Mtproto)
return (proxy.type == MTP::ProxyData::Type::Mtproto
|| proxy.type == MTP::ProxyData::Type::Web)
? std::make_pair(proxy.host, proxy.port)
: std::make_pair(QString(), uint32(0));
};

View File

@@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "mtproto/connection_tcp.h"
#include "mtproto/details/mtproto_abstract_socket.h"
#include "mtproto/details/mtproto_web_proxy_socket.h"
#include "base/bytes.h"
#include "base/openssl_help.h"
#include "base/random.h"
@@ -514,10 +515,12 @@ void TcpConnection::connectToServer(
Expects(_protocol == nullptr);
Expects(_protocolDcId == 0);
const auto secret = (_proxy.type == ProxyData::Type::Mtproto)
const auto proxyProtocol = (_proxy.type == ProxyData::Type::Mtproto)
|| (_proxy.type == ProxyData::Type::Web);
const auto secret = proxyProtocol
? _proxy.secretFromMtprotoPassword()
: protocolSecret;
if (_proxy.type == ProxyData::Type::Mtproto) {
if (proxyProtocol) {
_address = _proxy.host;
_port = _proxy.port;
_protocol = Protocol::Create(secret);
@@ -526,11 +529,13 @@ void TcpConnection::connectToServer(
_port = port;
_protocol = Protocol::Create(secret);
}
_socket = AbstractSocket::Create(
thread(),
secret,
ToNetworkProxy(_proxy),
protocolForFiles);
_socket = (_proxy.type == ProxyData::Type::Web)
? std::make_unique<WebProxySocket>(thread(), _proxy)
: AbstractSocket::Create(
thread(),
secret,
ToNetworkProxy(_proxy),
protocolForFiles);
_protocolDcId = protocolDcId;
const auto postfix = _socket->debugPostfix();
@@ -538,7 +543,11 @@ void TcpConnection::connectToServer(
.arg(_debugId.toInt())
.arg(
ProtocolDcDebugId(_protocolDcId),
(_proxy.type == ProxyData::Type::Mtproto) ? "mtproxy " : "",
(_proxy.type == ProxyData::Type::Mtproto)
? "mtproxy "
: (_proxy.type == ProxyData::Type::Web)
? "webproxy "
: "",
_address)
.arg(_port)
.arg(postfix.isEmpty() ? _protocol->debugPostfix() : postfix);

View File

@@ -0,0 +1,161 @@
/*
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/details/mtproto_web_proxy_socket.h"
#include "mtproto/web_proxy/web_proxy_transport.h"
namespace MTP::details {
WebProxySocket::WebProxySocket(
not_null<QThread*> thread,
const ProxyData &proxy)
: AbstractSocket(thread)
, _streamId(WebProxy::Transport::NextStreamId())
, _transport(WebProxy::Transport::Instance()) {
Expects(proxy.type == ProxyData::Type::Web);
}
WebProxySocket::~WebProxySocket() {
if (_transport) {
_transport->closeStream(_streamId);
}
}
void WebProxySocket::connectToHost(const QString &, int) {
Expects(_state == State::NotConnected);
_state = State::Connecting;
if (_transport) {
_transport->registerStream(_streamId, {
.context = this,
.connected = [=] { transportConnected(); },
.data = [=](QByteArray data) {
transportData(std::move(data));
},
.disconnected = [=] { transportDisconnected(); },
.failed = [=] { transportFailed(); },
});
} else {
transportFailed();
}
}
bool WebProxySocket::isGoodStartNonce(bytes::const_span nonce) {
Expects(nonce.size() >= 2 * sizeof(uint32));
const auto bytes = nonce.data();
const auto zero = *reinterpret_cast<const uchar*>(bytes);
const auto first = *reinterpret_cast<const uint32*>(bytes);
const auto second = *(reinterpret_cast<const uint32*>(bytes) + 1);
return (zero != 0xEFU)
&& (first != 0x44414548U)
&& (first != 0x54534F50U)
&& (first != 0x20544547U)
&& (first != 0xEEEEEEEEU)
&& (first != 0xDDDDDDDDU)
&& (first != 0x02010316U)
&& (second != 0x00000000U);
}
void WebProxySocket::timedOut() {
}
bool WebProxySocket::isConnected() {
return (_state == State::Connected);
}
bool WebProxySocket::hasBytesAvailable() {
return (_incomingOffset < _incoming.size());
}
int64 WebProxySocket::read(bytes::span buffer) {
const auto available = _incoming.size() - _incomingOffset;
if (available <= 0) {
return 0;
}
const auto count = int(std::min<std::size_t>(available, buffer.size()));
if (!count) {
return 0;
}
bytes::copy(
buffer,
bytes::make_span(_incoming).subspan(_incomingOffset, count));
_incomingOffset += count;
if (_incomingOffset == _incoming.size()) {
_incoming.clear();
_incomingOffset = 0;
} else if (_incomingOffset >= 64 * 1024
&& _incomingOffset >= _incoming.size() / 2) {
_incoming.remove(0, _incomingOffset);
_incomingOffset = 0;
}
if (_transport) {
_transport->grantWindow(_streamId, count);
}
return count;
}
void WebProxySocket::write(
bytes::const_span prefix,
bytes::const_span buffer) {
Expects(!buffer.empty());
if (!isConnected() || !_transport) {
return;
}
auto data = QByteArray(
int(prefix.size() + buffer.size()),
Qt::Uninitialized);
if (!prefix.empty()) {
memcpy(data.data(), prefix.data(), prefix.size());
}
memcpy(data.data() + prefix.size(), buffer.data(), buffer.size());
_transport->sendData(_streamId, std::move(data));
}
int32 WebProxySocket::debugState() {
return int32(_state);
}
QString WebProxySocket::debugPostfix() const {
return u"_web"_q;
}
void WebProxySocket::transportConnected() {
if (_state != State::Connecting) {
return;
}
_state = State::Connected;
_connected.fire({});
}
void WebProxySocket::transportData(QByteArray data) {
if (_state != State::Connected || data.isEmpty()) {
return;
}
_incoming.append(data);
_readyRead.fire({});
}
void WebProxySocket::transportDisconnected() {
if (_state == State::Disconnected || _state == State::Error) {
return;
}
_state = State::Disconnected;
_disconnected.fire({});
}
void WebProxySocket::transportFailed() {
if (_state == State::Disconnected || _state == State::Error) {
return;
}
_state = State::Error;
_error.fire({});
}
} // namespace MTP::details

View File

@@ -0,0 +1,61 @@
/*
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
*/
#pragma once
#include "mtproto/details/mtproto_abstract_socket.h"
#include "mtproto/mtproto_proxy_data.h"
#include <QtCore/QByteArray>
namespace MTP::WebProxy {
class Transport;
} // namespace MTP::WebProxy
namespace MTP::details {
class WebProxySocket final : public AbstractSocket {
public:
WebProxySocket(
not_null<QThread*> thread,
const ProxyData &proxy);
~WebProxySocket();
void connectToHost(const QString &address, int port) override;
bool isGoodStartNonce(bytes::const_span nonce) override;
void timedOut() override;
bool isConnected() override;
bool hasBytesAvailable() override;
int64 read(bytes::span buffer) override;
void write(bytes::const_span prefix, bytes::const_span buffer) override;
int32 debugState() override;
QString debugPostfix() const override;
private:
enum class State {
NotConnected,
Connecting,
Connected,
Disconnected,
Error,
};
void transportConnected();
void transportData(QByteArray data);
void transportDisconnected();
void transportFailed();
const uint32 _streamId;
WebProxy::Transport *_transport = nullptr;
QByteArray _incoming;
int _incomingOffset = 0;
State _state = State::NotConnected;
};
} // namespace MTP::details

View File

@@ -10,6 +10,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "base/qthelp_url.h"
#include "base/qt/qt_string_view.h"
#include <QtCore/QCryptographicHash>
#include <QtCore/QMessageAuthenticationCode>
#include <QtCore/QUrl>
#include <QtNetwork/QHostAddress>
namespace MTP {
namespace {
@@ -143,14 +148,112 @@ namespace {
return bytes::make_vector(bytes::make_span(result));
}
[[nodiscard]] QString ComputeWebProxyBridgeCapability(
const QString &host,
const QByteArray &key) {
const auto context = QByteArray("tdesktop-web-proxy-bridge-v1\n")
+ host.toLatin1();
return QString::fromLatin1(QMessageAuthenticationCode::hash(
context,
key,
QCryptographicHash::Sha256
).toBase64(
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
} // namespace
QString NormalizeWebProxyHost(const QString &value) {
const auto input = value.trimmed();
if (input.isEmpty()
|| input.contains(':')
|| input.contains('/')
|| input.contains('?')
|| input.contains('#')
|| input.contains('@')
|| input.endsWith('.')) {
return QString();
}
const auto result = QString::fromLatin1(QUrl::toAce(input)).toLower();
if (result.isEmpty()
|| result.size() > 253
|| !result.contains('.')) {
return QString();
}
for (const auto &label : result.split('.')) {
if (label.isEmpty()
|| label.size() > 63
|| label.front() == '-'
|| label.back() == '-') {
return QString();
}
for (const auto ch : label) {
if (!ch.isLetterOrNumber() && ch != '-') {
return QString();
}
}
}
auto address = QHostAddress();
return address.setAddress(result) ? QString() : result;
}
QString WebProxyBridgeCapability(const ProxyData &proxy) {
Expects(proxy.type == ProxyData::Type::Web);
Expects(proxy.host == NormalizeWebProxyHost(proxy.host));
#ifndef NDEBUG
static const auto checked = [] {
Assert(NormalizeWebProxyHost(u" Proxy.Example.COM "_q)
== u"proxy.example.com"_q);
Assert(NormalizeWebProxyHost(u"bücher.example"_q)
== u"xn--bcher-kva.example"_q);
Assert(NormalizeWebProxyHost(u"localhost"_q).isEmpty());
Assert(NormalizeWebProxyHost(u"127.0.0.1"_q).isEmpty());
Assert(NormalizeWebProxyHost(u"site.example:443"_q).isEmpty());
Assert(NormalizeWebProxyHost(u"site..example"_q).isEmpty());
const auto plain = QByteArray::fromHex(
"000102030405060708090a0b0c0d0e0f");
const auto padded = QByteArray::fromHex(
"dd000102030405060708090a0b0c0d0e0f");
Assert(ComputeWebProxyBridgeCapability(
u"proxy.example.com"_q,
plain) == u"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"_q);
Assert(ComputeWebProxyBridgeCapability(
u"proxy.example.com"_q,
padded) == u"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"_q);
return true;
}();
(void)checked;
#endif // !NDEBUG
const auto secret = proxy.secretFromMtprotoPassword();
Expects(!secret.empty());
const auto key = QByteArray(
reinterpret_cast<const char*>(secret.data()),
int(secret.size()));
return ComputeWebProxyBridgeCapability(proxy.host, key);
}
bool ProxyData::valid() const {
return status() == Status::Valid;
}
ProxyData::Status ProxyData::status() const {
if (type == Type::None || host.isEmpty() || !port) {
if (type == Type::Web) {
if (host != NormalizeWebProxyHost(host)
|| port != 443
|| !user.isEmpty()) {
return Status::Invalid;
}
const auto result = MtprotoPasswordStatus(password);
if (result != Status::Valid) {
return result;
}
const auto secret = secretFromMtprotoPassword();
return (secret.size() >= 21 && secret[0] == bytes::type(0xEE))
? Status::Unsupported
: Status::Valid;
} else if (type == Type::None || host.isEmpty() || !port) {
return Status::Invalid;
} else if (type == Type::Mtproto) {
return MtprotoPasswordStatus(password);
@@ -172,7 +275,7 @@ bool ProxyData::tryCustomResolve() const {
}
bytes::vector ProxyData::secretFromMtprotoPassword() const {
Expects(type == Type::Mtproto);
Expects(type == Type::Mtproto || type == Type::Web);
if (IsHexMtprotoPassword(password)) {
return SecretFromHexMtprotoPassword(password);
@@ -232,7 +335,8 @@ ProxyData ToDirectIpProxy(const ProxyData &proxy, int ipIndex) {
QNetworkProxy ToNetworkProxy(const ProxyData &proxy) {
if (proxy.type == ProxyData::Type::None) {
return QNetworkProxy::DefaultProxy;
} else if (proxy.type == ProxyData::Type::Mtproto) {
} else if (proxy.type == ProxyData::Type::Mtproto
|| proxy.type == ProxyData::Type::Web) {
return QNetworkProxy::NoProxy;
}
return QNetworkProxy(

View File

@@ -22,6 +22,7 @@ struct ProxyData {
Socks5,
Http,
Mtproto,
Web,
};
enum class Status {
Valid,
@@ -53,6 +54,8 @@ struct ProxyData {
};
[[nodiscard]] QString NormalizeWebProxyHost(const QString &value);
[[nodiscard]] QString WebProxyBridgeCapability(const ProxyData &proxy);
[[nodiscard]] ProxyData ToDirectIpProxy(
const ProxyData &proxy,
int ipIndex = 0);

View File

@@ -49,6 +49,9 @@ void StartProxyCheck(
using Variants = DcOptions::Variants;
ResetProxyCheckers(v4, v6);
if (proxy.type == ProxyData::Type::Web) {
return;
}
const auto connType = (proxy.type == ProxyData::Type::Http)
? Variants::Http
: Variants::Tcp;

View File

@@ -242,7 +242,8 @@ void Session::refreshOptions() {
const auto isEnabled = settings.isEnabled();
const auto proxyType = (isEnabled ? proxy.type : ProxyData::Type::None);
const auto useTcp = (proxyType != ProxyData::Type::Http);
const auto useHttp = (proxyType != ProxyData::Type::Mtproto);
const auto useHttp = (proxyType != ProxyData::Type::Mtproto)
&& (proxyType != ProxyData::Type::Web);
const auto useIPv4 = true;
const auto useIPv6 = settings.tryIPv6();
_data->setOptions(SessionOptions(

View File

@@ -687,7 +687,8 @@ void SessionPrivate::tryToSend() {
: _instance->systemVersion();
const auto appVersion = ComputeAppVersion();
const auto proxyType = _options->proxy.type;
const auto mtprotoProxy = (proxyType == ProxyData::Type::Mtproto);
const auto mtprotoProxy = (proxyType == ProxyData::Type::Mtproto)
|| (proxyType == ProxyData::Type::Web);
const auto clientProxyFields = mtprotoProxy
? MTP_inputClientProxy(
MTP_string(_options->proxy.host),
@@ -1034,7 +1035,8 @@ void SessionPrivate::connectToServer(bool afterConfig) {
return;
}
}
if (_options->proxy.type == ProxyData::Type::Mtproto) {
if (_options->proxy.type == ProxyData::Type::Mtproto
|| _options->proxy.type == ProxyData::Type::Web) {
// host, port, secret for mtproto proxy are taken from proxy.
appendTestConnection(DcOptions::Variants::Tcp, {}, 0, {});
} else {

View File

@@ -0,0 +1,94 @@
/*
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/web_proxy/web_proxy_frame.h"
namespace MTP::WebProxy {
namespace {
[[nodiscard]] bool IsKnownType(uchar value) {
switch (FrameType(value)) {
case FrameType::Open:
case FrameType::Data:
case FrameType::Close:
case FrameType::Window:
case FrameType::Ping:
case FrameType::Pong:
case FrameType::Hello:
case FrameType::Welcome:
case FrameType::AuthChallenge:
case FrameType::AuthResponse:
case FrameType::Bye:
return true;
}
return false;
}
} // namespace
QByteArray SerializeFrame(
FrameType type,
uint32 streamId,
const QByteArray &payload) {
Expects(streamId <= 0x00FFFFFF);
Expects(payload.size() <= kMaxFramePayload);
auto result = QByteArray(kFrameHeaderSize + payload.size(), Qt::Uninitialized);
auto data = reinterpret_cast<uchar*>(result.data());
data[0] = uchar(type);
data[1] = uchar(streamId >> 16);
data[2] = uchar(streamId >> 8);
data[3] = uchar(streamId);
const auto size = uint32(payload.size());
data[4] = uchar(size >> 24);
data[5] = uchar(size >> 16);
data[6] = uchar(size >> 8);
data[7] = uchar(size);
if (!payload.isEmpty()) {
memcpy(result.data() + kFrameHeaderSize, payload.constData(), payload.size());
}
return result;
}
bool ParseFrames(QByteArray &buffer, std::vector<Frame> &output) {
auto offset = 0;
while (buffer.size() - offset >= kFrameHeaderSize) {
const auto data = reinterpret_cast<const uchar*>(buffer.constData() + offset);
if (!IsKnownType(data[0])) {
return false;
}
const auto streamId = (uint32(data[1]) << 16)
| (uint32(data[2]) << 8)
| uint32(data[3]);
const auto size = (uint32(data[4]) << 24)
| (uint32(data[5]) << 16)
| (uint32(data[6]) << 8)
| uint32(data[7]);
if (size > kMaxFramePayload) {
return false;
}
const auto full = kFrameHeaderSize + int(size);
if (buffer.size() - offset < full) {
break;
}
if (output.size() >= std::size_t(kMaxBatchFrames)) {
return false;
}
output.push_back({
FrameType(data[0]),
streamId,
buffer.mid(offset + kFrameHeaderSize, size),
});
offset += full;
}
if (offset > 0) {
buffer.remove(0, offset);
}
return true;
}
} // namespace MTP::WebProxy

View File

@@ -0,0 +1,51 @@
/*
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
*/
#pragma once
#include "base/basic_types.h"
#include <QtCore/QByteArray>
#include <vector>
namespace MTP::WebProxy {
inline constexpr auto kFrameHeaderSize = 8;
inline constexpr auto kMaxFramePayload = 1024 * 1024;
inline constexpr auto kMaxBatchFrames = 4096;
inline constexpr auto kInitialStreamWindow = 4 * 1024 * 1024;
enum class FrameType : uchar {
Open = 0x01,
Data = 0x02,
Close = 0x03,
Window = 0x04,
Ping = 0x05,
Pong = 0x06,
Hello = 0x10,
Welcome = 0x11,
AuthChallenge = 0x12,
AuthResponse = 0x13,
Bye = 0x1F,
};
struct Frame {
FrameType type = FrameType::Bye;
uint32 streamId = 0;
QByteArray payload;
};
[[nodiscard]] QByteArray SerializeFrame(
FrameType type,
uint32 streamId,
const QByteArray &payload = QByteArray());
[[nodiscard]] bool ParseFrames(
QByteArray &buffer,
std::vector<Frame> &output);
} // namespace MTP::WebProxy

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
/*
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
*/
#pragma once
#include "mtproto/mtproto_proxy_data.h"
#include <QtCore/QObject>
#include <QtCore/QPointer>
#include <rpl/producer.h>
#include <atomic>
#include <memory>
namespace MTP::WebProxy {
class Transport final : public QObject {
Q_OBJECT
public:
enum class State {
Idle,
WaitingForBrowser,
Connecting,
Connected,
Failed,
};
struct StateChange {
ProxyData proxy;
State state = State::Idle;
QString browser;
};
struct StreamHandlers {
QPointer<QObject> context;
Fn<void()> connected;
Fn<void(QByteArray)> data;
Fn<void()> disconnected;
Fn<void()> failed;
};
static void Activate(const ProxyData &proxy, bool openBrowser = true);
static void Deactivate();
static void Shutdown();
[[nodiscard]] static Transport *Instance();
[[nodiscard]] static State CurrentState(const ProxyData &proxy);
[[nodiscard]] static rpl::producer<StateChange> StateChanges();
static void OpenBrowser(const ProxyData &proxy);
[[nodiscard]] static uint32 NextStreamId();
void registerStream(uint32 streamId, StreamHandlers handlers);
void closeStream(uint32 streamId);
void sendData(uint32 streamId, QByteArray data);
void grantWindow(uint32 streamId, uint32 amount);
~Transport();
private:
Transport();
[[nodiscard]] bool reservePending(int bytes);
void releasePending(int bytes, int items);
class Private;
const std::unique_ptr<Private> _private;
std::atomic<int64> _pendingBytes = 0;
std::atomic<int> _pendingItems = 0;
};
} // namespace MTP::WebProxy

419
docs/web-proxy-plan.md Normal file
View File

@@ -0,0 +1,419 @@
# Telegram Desktop WEB proxy: refined client design
The hosted half is specified in `../tproxy-server/PLAN.md`. Its multiplexing frame
format and `MessageChannel` contract are authoritative. This document records the
reviewed Telegram Desktop design and the implementation now present in this tree.
The server-dependent execution procedure is intentionally separate in
`docs/web-proxy-test-plan.md`.
## 1. Scope and invariant
WEB is an MTProxy whose carrier is the user's real browser:
```text
MTProto session threads
-> TcpConnection (existing MTProxy obfuscation and AES-CTR)
-> WebProxySocket (one logical stream)
-> process-wide WebProxy::Transport (one worker thread)
-> authenticated ws://127.0.0.1:<random>/transport
-> loopback parent page
-> MessageChannel
-> https://relay.example/?bridge=<derived-capability> iframe
-> HTTPS carrier
-> hosted relay
-> stock MTProxy
-> Telegram
```
The central invariant is that Telegram Desktop opens no external MTProto socket
while WEB is active. The only external connection in this path is made by the real
browser. The browser and hosted relay see only bytes already transformed by the
existing MTProxy protocol layer; the MTProxy secret is never placed in HTML or
JavaScript.
## 2. Design decisions
The initial draft left several architectural choices open. They are now fixed:
1. There is one transport per process, not per account. Proxy selection is already
process-wide, so all accounts using the selected WEB proxy share one browser tab
and one multiplexed browser carrier.
2. The transport owns a dedicated `QThread`. `QTcpServer`, accepted loopback
sockets, WebSocket framing, mux state, queues, and windows live only there.
3. `WebProxySocket` and the transport are compiled in the main `Telegram` target.
`connection_tcp.cpp`, the only factory site retaining the full `ProxyData`, is
already in that target. No reverse dependency from `td_mtproto` is introduced.
4. The serialized `host` field stores only the canonical lowercase ASCII/IDNA
A-label hostname. Scheme, port, path, query, fragment, user info, IP addresses,
and single-label names are rejected. `port` is fixed to `443`; `password` stores
the MTProxy secret.
5. WEB is manual-entry-only in v1. It has no `tg://proxy` share/import format.
6. Inactive WEB entries are not checked and are removed from proxy rotation's
candidate order in v1. Checking would require activating a browser sidecar and
must never open tabs for every saved proxy.
7. The loopback parent is one inline, dependency-free HTML response. A qrc asset
adds no value for this small page and would create another generated-resource
dependency.
8. RTC keepalive is not part of v1. Browser/tab loss is an ordinary transport loss;
Telegram's existing reconnect machinery handles it.
## 3. Data model and persistence
`MTP::ProxyData::Type::Web` is appended to the enum and serialized as type code `4`.
The existing five-field proxy blob remains unchanged:
```text
type | host | port | user | password
```
WEB maps those fields as follows:
| Field | WEB meaning |
|---|---|
| `host` | canonical lowercase ASCII/IDNA A-label hostname |
| `port` | fixed value `443` |
| `user` | empty |
| `password` | existing MTProxy secret syntax |
Validation requires both a valid DNS hostname and a supported MTProxy secret. Plain
16-byte and `dd` random-padding secrets are accepted; `ee` TLS-emulation secrets are
rejected because the stock MTProxy would expect an inner TLS-emulation record that
this raw relay deliberately does not add. Unknown future serialized type codes
deserialize to `None` instead of reaching
`Unexpected`, so downgrades skip an unsupported proxy rather than crashing.
WEB behaves like MTProxy throughout the existing model:
- `secretFromMtprotoPassword()` accepts WEB.
- Qt's application proxy is `NoProxy`; WEB does not affect update or generic HTTP
traffic.
- custom DC/proxy DNS resolution is disabled because the browser resolves the relay
hostname.
- calls remain unsupported.
- TCP MTProto is enabled and the plain MTProto HTTP connection is disabled.
- DC endpoints are ignored; the hosted relay chooses its fixed stock-MTProxy target.
- `initConnection` reports the relay hostname and port 443 as client proxy metadata.
## 4. `WebProxySocket`
`mtproto/details/mtproto_web_proxy_socket.*` implements `AbstractSocket` as a
logical byte stream over the shared transport.
On `connectToHost`, it registers a new 24-bit stream id. The address and port
arguments are intentionally ignored. It emits `connected` after the browser carrier
has completed the relay `WELCOME` handshake and the transport has sent `OPEN` for
that stream.
Writes concatenate the one-time MTProxy connection prefix and body before queuing a
`DATA` frame. Incoming `DATA` is buffered and exposed through partial `read()` calls.
Every successful read replenishes exactly that many bytes of receive credit with a
`WINDOW` frame. Transport loss emits `disconnected`; protocol violations, queue
overflow, and explicit transport failures emit `error`.
The existing `TcpConnection` continues to own all MTProxy protocol work. For WEB it
uses `secretFromMtprotoPassword()` and `Protocol::Create(secret)` exactly as for
MTProxy, then selects `WebProxySocket` at the one socket-factory call site.
## 5. Process-wide transport and threading
`mtproto/web_proxy/web_proxy_transport.*` provides a main-thread lifecycle facade and
runs all I/O state on its worker thread.
Main-thread lifecycle:
- `Activate(proxy)` creates the worker on first use, synchronously installs the
selected valid proxy, binds the loopback listener, and auto-opens one browser tab
when the selected WEB proxy changes.
- `OpenBrowser(proxy)` mints a fresh one-shot capability and opens a new tab on
explicit user request.
- `Deactivate()` closes streams, accepted clients, and the listener when the app
changes away from WEB.
- `Shutdown()` runs after MTP accounts have stopped and joins the worker thread.
Session-thread interaction uses queued calls into the worker. Each stream stores its
socket context, and worker-to-socket delivery is queued to that socket's owning
thread. `WebProxySocket` destruction unregisters synchronously on the worker before
the QObject base destructor can invalidate the context. This creates a strict
ordering boundary: notifications already posted remain owned by Qt and are removed
with the QObject, while the worker cannot inspect or post through the context after
unregistration returns. The global transport pointer is atomic and remains alive
until all MTP sessions have been destroyed.
The state surfaced to settings is:
```text
Idle
-> WaitingForBrowser
-> Connecting
-> Connected
-> WaitingForBrowser (tab/local WS lost)
-> Failed (protocol/relay failure)
```
## 6. Shared relay frames
All integers are big-endian. The implementation mirrors server plan section 7:
```text
type:u8 | stream_id:u24 | length:u32 | payload:length
```
Each browser carrier message must contain one or more complete frames. The parser
accepts concatenated frames and rejects an empty message or trailing partial frame.
A payload is capped at 1 MiB. Known types are:
| Value | Name | Stream | Client behavior |
|---:|---|---:|---|
| `0x01` | `OPEN` | >0 | sent once after `WELCOME` |
| `0x02` | `DATA` | >0 | opaque MTProxy bytes |
| `0x03` | `CLOSE` | >0 | empty payload; closes one logical socket |
| `0x04` | `WINDOW` | >0 | four-byte credit delta |
| `0x05` | `PING` | 0 | relay-to-client keepalive; answered with `PONG` |
| `0x06` | `PONG` | 0 | sent only as the exact `PING` response |
| `0x10` | `HELLO` | 0 | client sends payload `01` for protocol v1 |
| `0x11` | `WELCOME` | 0 | empty payload; must be the first relay frame |
| `0x12` | `AUTH_CHAL` | 0 | reserved for relay-auth v2, rejected in v1 |
| `0x13` | `AUTH_RESP` | 0 | reserved for relay-auth v2 |
| `0x1f` | `BYE` | 0 | fails current logical streams and closes the carrier |
An incoming `OPEN`, a stream frame on stream zero, a session frame on a nonzero
stream, malformed `WINDOW`, data beyond granted credit, an unknown live stream, or
an unknown type is a protocol error for v1. The client retains up to 4096 recently
closed stream ids. Well-formed `DATA`, `WINDOW`, and `CLOSE` already in flight for a
retained id are discarded; this prevents an ordinary cross-direction close race from
failing unrelated multiplexed streams.
## 7. Flow control and memory bounds
Both directions start with an implicit 4 MiB per-stream window.
Downlink flow control is exact: relay `DATA` consumes client receive credit, and
Telegram Desktop grants it back only when `WebProxySocket::read()` drains bytes into
the MTProto engine. This naturally bounds each socket's unread data.
Uplink has a constraint the initial draft missed: `AbstractSocket::write()` returns
`void` and provides no writable/backpressure event, so it cannot stop the MTProto
caller and resume later. The client therefore:
- spends relay-granted send credit before emitting each `DATA` frame;
- splits outgoing data into at most 64 KiB frames;
- queues excess data per stream;
- coalesces adjacent writes up to 64 KiB and avoids front-removal copies;
- fails the stream if its pending uplink exceeds 8 MiB or 1024 queued items;
- caps all queued cross-thread uplink data at 64 MiB and 8192 items;
- pauses stream flushing when the process-wide loopback socket write queue reaches
4 MiB and resumes it as bytes drain;
- reserves 64 KiB of that socket budget for control traffic and bounds a separate
64 KiB / 1024-frame control queue; and
- schedules ready streams round-robin, with at most 256 frames per worker turn.
If the browser socket makes no write progress for 30 seconds, the carrier fails and
normal MTProto reconnect logic replaces it. Exhausting a stream or transport budget
also fails promptly rather than allowing unbounded queued worker events. If
measurements show sustained multi-megabyte uploads can exhaust these bounds, a
future change must add writable backpressure to the `AbstractSocket` contract rather
than silently growing memory.
### 7.1 Performance envelope and built-in HTTP comparison
The hosted bridge batches up to 2 MiB and runs uplink and downlink concurrently.
Each direction is sequenced stop-and-wait in v1, giving an RTT-only busy-direction
bound of 40, 20, 10, and 4 MiB/s at 50, 100, 200, and 500 ms browser-to-relay RTT,
respectively. Actual results include transfer time, the relay-to-MTProxy leg, and
browser scheduling. The 4 MiB stream window is two carrier batches so returned
credit does not reproduce the former 256 KiB bottleneck.
The built-in MTProto HTTP transport also copies request/response bodies and uses an
HTTP wait request, but `QNetworkAccessManager` may keep several POSTs active. WEB is
therefore more RTT-sensitive today. That serialization, fixed batch size, and most
buffer copies are implementation choices; a bounded ordered pipeline or compatible
streaming carrier can narrow them. Inherent WEB cost remains one browser process,
an extra relay/TLS path, MessageChannel/loopback crossings, and shared-carrier
head-of-line exposure. With a well-placed relay, ordinary messaging and moderate
media should be in the same practical class as the built-in HTTP transport, while
direct TCP/MTProxy remains the latency and peak-throughput reference.
## 8. Loopback HTTP and WebSocket boundary
The worker binds `QHostAddress::LocalHost` on an ephemeral port and advertises the
numeric origin `http://127.0.0.1:<port>`.
`GET /` serves the inline parent with `no-store`, `nosniff`, `no-referrer`, and a
fresh per-response script nonce. Its strict CSP permits only that nonce-bound
bootstrap, the configured HTTPS iframe origin, and its exact local WebSocket
endpoint.
`GET /transport` upgrades to RFC 6455 only when all of the following hold:
- peer address is loopback;
- method/path are exactly `GET /` or the `/transport` upgrade;
- `Host` is the exact numeric loopback host and current port;
- `Origin` is the exact loopback page origin;
- `Upgrade`, `Connection`, version 13, and a valid 16-byte key are present;
- duplicate HTTP header names are rejected;
- request bodies and transfer encodings are rejected on the local GET boundary;
- the HTTP header block is at most 16 KiB.
Client WebSocket frames must be masked. The parser supports 7/16/64-bit lengths,
text, binary, continuation, ping, pong, and close, with a 2 MiB message cap. Server
frames are unmasked as required by RFC 6455.
An accepted local client must complete capability authentication within ten seconds.
This bounds silent HTTP connections and unauthenticated WebSockets so they cannot
hold all 32 local client slots indefinitely.
The first complete WebSocket message must be UTF-8 JSON:
```json
{"t":"auth","token":"<capability>","browser":"<user agent summary>"}
```
The capability is 256 random bits, URL-safe base64, carried only in the fragment of
the browser URL. The page removes it from the visible URL immediately. It is
one-shot, expires after five minutes, and is replaced when another tab is opened. A
newly authenticated tab replaces the previous authenticated tab and causes MTProto
streams to reconnect rather than attempting unsupported cross-tab resume.
After authentication:
- binary WebSocket messages carry one or more shared relay frames;
- text messages may only report bridge state as
`{"t":"status","state":"connecting|connected|reconnecting|failed"}`.
If an authenticated browser does not return the required `WELCOME` within 30
seconds, the client fails that carrier and closes its local WebSocket. This turns a
wrong bridge capability, iframe load failure, or ordinary public response into a
recoverable `unavailable` state instead of leaving the settings row connecting
forever.
## 9. Parent page and hosted iframe contract
The local parent reads and scrubs its independent one-shot loopback capability,
connects the local WebSocket, derives the bridge URL, creates an iframe with limited
`sandbox` flags, and establishes a `MessageChannel`.
For a canonical hostname `H` and decoded WEB secret bytes `S`, including the leading
`dd` byte when present, it computes:
```text
context = UTF-8("tdesktop-web-proxy-bridge-v1\n" + H)
bridge = base64url-no-padding(HMAC-SHA256(key=S, message=context))
bridgeUrl = "https://" + H + "/?bridge=" + bridge
```
Normative vectors:
| Hostname | Decoded secret hex | `bridge` |
|---|---|---|
| `proxy.example.com` | `000102030405060708090a0b0c0d0e0f` | `MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk` |
| `proxy.example.com` | `dd000102030405060708090a0b0c0d0e0f` | `IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA` |
The derived capability is constructed in memory and is neither stored nor shown in
proxy settings. On iframe load the parent sends exactly:
```javascript
iframe.contentWindow.postMessage(
{ t: 'tproxy-init', v: 1 },
relayOrigin,
[channel.port2]);
```
The target origin is exact and never `*`. Binary messages are transferred as
`ArrayBuffer`s in both directions. Frames received locally before iframe
initialization are queued briefly and transferred after initialization. The parent
does not parse shared relay frames and never receives the MTProxy secret. Both the
hosted uplink queue and the parent's local-WebSocket queue are capped at 32 MiB; the
hosted queue also caps retained buffer objects at 16384. Exceeding either bound
closes the carrier instead of growing browser memory without limit.
The iframe's status objects update the visible tab and are forwarded to tdesktop.
When the local WebSocket closes, the parent sends `{t:'close'}` so the bridge can
delete its relay session. Closing the tab drops the local WebSocket, disconnects all
logical sockets, and leaves the settings row in `waiting for browser…`. Telegram
Desktop does not reopen a tab automatically after a user closes it. Reloading cannot
reuse the scrubbed, one-shot loopback capability either. In both cases the row menu
provides `Open browser`, which mints a fresh loopback capability and opens a new tab.
## 10. Settings and app integration
Proxy settings expose a fourth `WEB` radio option. The editor shows:
- one proxy hostname field;
- one MTProxy secret field;
- no socket host/port pair and no username/password controls.
Rows display only the hostname. The selected row
shows the transport lifecycle, and its menu has `Open browser`. WEB remains
non-shareable and unsupported for calls. Because the backend is still MTProxy, WEB
keeps the existing sponsored-proxy disclosure and promotion refresh behavior.
Application proxy changes configure/deconfigure the browser transport before MTP
sessions restart. WEB follows the MTProxy path in `Session`, `SessionPrivate`, and
`TcpConnection`; the global Qt proxy remains disabled for it. Proxy rotation and the
settings availability checker deliberately treat inactive WEB entries as unavailable
instead of opening a browser.
## 11. Constraints and boundaries
- The listener is IPv4 loopback-only and validates peer, host, and origin.
- Local authentication requires the minted fragment capability.
- The local protocol has no arbitrary destination command. `OPEN` originates only
from tdesktop and the relay is expected to dial one configured stock MTProxy.
- The configured value is a canonical DNS hostname; HTTPS and port 443 are fixed.
- The bridge URL contains only the domain-separated derived capability, never the raw
MTProxy secret.
- Frame, WebSocket, HTTP-header, local-client-count, receive-window, and
pending-uplink bounds prevent unbounded buffering.
- The parent iframe uses only `sandbox="allow-scripts allow-same-origin"`.
- Payloads and secrets are never logged by this client code.
- WEB socket failures do not invoke tdesktop's direct HTTP time-sync fallback.
- Relay authentication (`AUTH_CHAL` / `AUTH_RESP`) is not implemented in v1. Adding
it requires a fully specified challenge context and server test vectors; it must be
computed in tdesktop without passing the secret to JavaScript.
## 12. Hosted-server requirements before execution testing
The server must provide all of these before the separate test plan can pass:
1. `https://<hostname>/?bridge=<derived-capability>` implements the exact derivation,
ordinary-site fallback, `MessageChannel`, close, and status contracts above.
2. Its CSP allows framing by random numeric loopback origins. A suitable source is
`http://127.0.0.1:*`; `X-Frame-Options` must not block the embed.
3. The bridge accepts the v1 `HELLO` frame, establishes a reliable ordered carrier,
and returns `WELCOME` before stream traffic.
4. The relay implements all v1 stream frames, the implicit 4 MiB windows, and
deduplicated/cursor-based reliability for polling carriers.
5. Every `OPEN` dials only the configured stock MTProxy endpoint.
6. The hosted code never logs frame payloads.
7. The v1 HTTPS long-poll carrier is operational; the deployed bridge does not
require a public WebSocket or another carrier.
## 13. Implementation inventory
Core transport:
- `Telegram/SourceFiles/mtproto/web_proxy/web_proxy_frame.{h,cpp}`
- `Telegram/SourceFiles/mtproto/web_proxy/web_proxy_transport.{h,cpp}`
- `Telegram/SourceFiles/mtproto/details/mtproto_web_proxy_socket.{h,cpp}`
Integration:
- `mtproto_proxy_data.*`, `core_settings_proxy.cpp`
- `connection_tcp.cpp`, `session.cpp`, `session_private.cpp`, `proxy_check.cpp`
- `application.cpp`, `main_account.cpp`
- `boxes/connection_box.{h,cpp}`, `lang.strings`
- `Telegram/CMakeLists.txt`
The client-side implementation is complete without the hosted server. The arm64
Debug build passes; remaining verification is the hosted protocol/loopback and
browser matrix in `docs/web-proxy-test-plan.md`, followed by other platform builds.
## 14. Explicitly deferred
- public deep-link/share format;
- checking inactive WEB proxies and auto-rotation into them;
- cross-tab or cross-process relay-session resume;
- relay-auth v2;
- RTC/background-tab keepalive tricks;
- alternate bridge paths, ports, or non-HTTPS relay origins;
- expanding `AbstractSocket` with true uplink writable backpressure.

407
docs/web-proxy-test-plan.md Normal file
View File

@@ -0,0 +1,407 @@
# Telegram Desktop WEB proxy: server-ready execution test plan
Execute this document only after a hosted relay matching
`../tproxy-server/PLAN.md` is deployed. Record every requested value and artifact in
one run log. Do not put the proxy secret, Telegram auth keys, message contents, or
the loopback fragment capability in logs or screenshots.
## 1. Release gate
WEB is ready for wider testing only when all P0 and P1 cases below pass on at least
one Chromium browser and the supported browser/platform matrix has no unexplained
P0 failure.
Severity:
- P0: transport boundary, corruption, crash, login, basic send/receive, or total
reconnect failure.
- P1: large transfer, concurrency, bounded memory, lifecycle recovery, or carrier
fallback failure.
- P2: status text, browser-specific lifecycle annoyance, or non-blocking polish.
## 2. Inputs to obtain from the server operator
Record these before starting:
| Input | Value |
|---|---|
| Relay hostname | `________________` |
| Bridge deployment/version | `________________` |
| Relay binary commit/version | `________________` |
| Stock MTProxy version | `________________` |
| MTProxy secret | store outside this document |
| Long-poll enabled | yes / no |
| Server log location | `________________` |
| Server metrics endpoint/dashboard | `________________` |
| Test start/end in UTC | `________________` |
| Tester network and country | `________________` |
The operator must confirm:
- the bridge root response can be framed by `http://127.0.0.1:*` and is not prevented
by `X-Frame-Options`;
- `HELLO` payload `01`, `WELCOME`, implicit 4 MiB windows, and all v1 frame types
match `docs/web-proxy-plan.md`;
- the polling carrier provides ordered retry/deduplication;
- `OPEN` can dial only the configured local stock MTProxy;
- payload logging is disabled.
Stop if any item is false. A client run cannot produce a meaningful result against
an incompatible bridge.
## 3. Client build and isolation
1. Build the exact candidate commit in Debug:
```bash
cmake --build out --config Debug --target Telegram
```
2. Record the commit, build timestamp, OS version, browser name/version, and whether
the browser is managed by enterprise policy.
3. Use a disposable Telegram test account and a separate portable/test profile. Do
not overwrite an existing personal portable profile.
4. Preserve Debug logs for the run, but verify they contain no proxy secret or frame
payload.
5. Start with all browser developer tools closed; opening DevTools changes background
throttling and would invalidate lifecycle observations.
6. Disable unrelated VPNs/proxies for baseline. Record DNS-over-HTTPS, browser proxy,
and system proxy state.
## 4. Hosted endpoint preflight
Run from the client machine:
```bash
curl -fsS -D /tmp/tproxy-site.headers -o /tmp/tproxy-site.body \
https://RELAY_HOSTNAME/
curl -fsS -D /tmp/tproxy-invalid.headers -o /tmp/tproxy-invalid.body \
'https://RELAY_HOSTNAME/?bridge=invalid'
```
Verify:
- TLS certificate and hostname are valid with no warning or redirect to HTTP;
- `/` looks like the intended ordinary site and contains no transport error details;
- an invalid or missing `bridge` query returns the same ordinary site without
transport-branded errors;
- server unit tests pass both capability vectors from `docs/web-proxy-plan.md`;
- the valid bridge response has a compatible `frame-ancestors` CSP and no
incompatible `X-Frame-Options`, verified without recording its query;
- transport APIs reject unsupported methods and malformed session identifiers;
- server health shows the stock MTProxy backend reachable.
Delete the captured bodies after inspection if the deployment embeds any
configuration. Keep sanitized headers with the run artifacts.
## 5. Configuration and first connection (P0)
1. Launch the candidate.
2. Open Settings -> Advanced -> Connection type -> Proxy settings -> Add proxy.
3. Select `WEB`.
4. Enter the relay hostname only and the MTProxy secret.
5. Confirm that these invalid inputs are rejected without saving:
- a value containing `http://` or `https://`;
- a hostname with an explicit port;
- a value with username, path, query, or fragment;
- an IPv4 or IPv6 address;
- a single-label name such as `localhost`;
- an invalid IDNA name, empty label, overlong label, or trailing dot;
- invalid or unsupported MTProxy secret;
- an `ee` TLS-emulation MTProxy secret;
- empty hostname or secret.
6. Save the valid entry and enable it.
7. Confirm exactly one default-browser tab opens at a numeric
`http://127.0.0.1:<ephemeral>/#<capability>` URL.
8. Confirm the fragment disappears immediately and the page says it is connecting.
9. Confirm the settings row moves through `waiting for browser…` / `connecting…` to
`online` and the tab says it is connected.
10. Confirm Telegram loads dialogs and receives updates.
Pass conditions:
- no crash, assertion, TLS warning, CORS/LNA prompt, mixed-content error, or iframe
refusal;
- server sees one relay session and the expected MTProxy stream connections;
- the iframe requests exactly `https://<canonical-host>/?bridge=<43-char-capability>`;
- the browser page, DOM, network request headers, and console never contain the
MTProxy secret;
- closing proxy settings does not affect the connection.
Repeat once with a profile secret that is valid MTProxy syntax but is not configured
on staging. The ordinary-site fallback must match other root responses, and the
client must leave `connecting…` for `not available` within 30 seconds rather than
waiting forever.
## 6. Network-origin invariant (P0)
While WEB is the only enabled Telegram proxy, collect connection ownership with an
OS tool. On macOS, for example:
```bash
lsof -nP -iTCP -sTCP:ESTABLISHED | egrep 'Telegram|Chrome|Chromium|Edge|Safari|Firefox'
```
Also capture a short packet trace or firewall connection log if permitted.
Verify:
- Telegram Desktop connects to `127.0.0.1:<ephemeral>` only for this transport;
- Telegram Desktop has no external connection to the relay origin, stock MTProxy,
or Telegram DCs for MTProto traffic;
- a WEB connection failure does not trigger tdesktop's HTTP time-sync fallback;
- the browser process owns the external HTTPS connection to the relay;
- DNS resolution of the relay is attributable to the browser/system resolver, not a
custom proxy/DC resolver in tdesktop;
- external carrier traffic remains inside the browser-owned TLS connection.
Account for unrelated Telegram HTTP traffic such as update checks before declaring
a failure. The invariant applies to the MTProto transport, not every auxiliary HTTP
request made by the application.
## 7. Functional traffic (P0/P1)
Run in order, checking both client behavior and relay/MTProxy stream metrics:
1. Fresh-account login, including code entry and 2FA if available. P0.
2. Dialog/history load and live incoming updates. P0.
3. Send and receive plain messages in private and group chats. P0.
4. Send and receive stickers, reactions, edits, deletes, and read receipts. P1.
5. Download thumbnails and several small media files. P0.
6. Download one file larger than 1 GiB. P1.
7. Upload one file large enough to exceed the 4 MiB window many times. P1.
8. Stream a video while downloading another file. P1.
9. Open media from CDN-backed storage and confirm shifted/CDN DC streams work. P1.
10. Leave the client idle for 30 minutes, then send and receive immediately. P1.
For large transfers record:
- bytes and final content hash;
- average and minimum throughput;
- tdesktop, browser, and relay peak memory;
- number of logical streams and stock-MTProxy sockets;
- reconnect/retry count;
- pending-uplink overflow or flow-control errors.
Run the same payload once through direct MTProxy and once through tdesktop's built-in
HTTP transport under the same controlled capacity/RTT when those controls are
reachable. On a controlled link with at least 100 Mbit/s capacity, WEB should
sustain at least 40 Mbit/s at 200 ms browser-to-relay RTT and 20 Mbit/s at 500 ms,
in both a foreground and an ordinarily hidden tab on each supported browser.
Also report WEB/direct-HTTP ratios for message round-trip p50/p95 and bulk transfer;
route differences must be reported separately from transport overhead.
No transfer may corrupt, silently truncate, duplicate an upload, or grow client
memory without returning toward baseline after completion.
## 8. Multiplexing and concurrency (P1)
1. Start at least 16 simultaneous media downloads across chats.
2. Send messages continuously during the downloads.
3. Start a large upload at the same time.
4. If multi-account is available, sign in to two disposable accounts and generate
traffic on both.
5. Confirm all logical streams use the same authenticated local WebSocket and browser
relay session.
6. Confirm one slow or window-exhausted stream does not block unrelated streams.
7. Cancel half the transfers and verify the corresponding `CLOSE`s release relay and
stock-MTProxy resources.
8. Let the remainder finish and compare hashes.
Monitor thread sanitizer output if a TSan build is practical. Otherwise run for at
least two hours while watching for cross-thread QObject warnings, stale stream
delivery, stream-id mixups, growing queues, and use-after-free crashes.
Add a focused destruction race run that repeatedly opens and destroys logical WEB
sockets while the worker concurrently delivers `connected`, `DATA`, `CLOSE`, and
failure notifications. Run it under ASan and TSan where supported. No notification
may begin after synchronous stream unregistration returns, and no callback may run
against a socket whose destructor has started.
## 9. Browser and transport lifecycle (P0/P1)
Execute each case from a connected baseline:
| Case | Expected result |
|---|---|
| Close the sidecar tab | all logical sockets disconnect; row says waiting; no tab auto-reopens |
| Row menu -> Open browser | fresh fragment capability; new tab authenticates; Telegram reconnects |
| Refresh the tab | consumed capability is not reusable; row says waiting; `Open browser` restores the connection with a fresh capability |
| Open browser twice | newest authenticated tab replaces the old one; no stream crosses sessions |
| Quit browser | same as tab loss; tdesktop remains responsive |
| Restart browser and use Open browser | clean reconnection |
| Restart tdesktop with WEB saved/enabled | new loopback port/token and one new tab; stale old tab cannot attach |
| Disable WEB | listener closes, browser loses local WS, normal connection policy resumes |
| Switch WEB A -> non-WEB -> WEB A | clean teardown and reactivation |
| Edit WEB hostname or secret | old transport closes; new settings take effect; no old relay traffic remains |
| System sleep 5 minutes | reconnect after wake without corruption or permanent spinner |
| Network down/up | browser carrier and MTProto recover within normal retry bounds |
Record whether the browser freezes/discards the active tab under battery/energy
saving. This is evidence for or against a future keepalive feature, not a v1 pass
condition if explicit `Open browser` recovers correctly.
## 10. Carrier reliability and server faults (P1)
Coordinate these with the server operator:
1. Drop an empty long-poll request or response. Verify bounded retry and continued
Telegram usability.
2. Drop one nonempty downlink response after the relay has assigned a cursor. Verify the next
request replays it once and tdesktop receives bytes once.
3. Drop an uplink response after the relay has processed the sequence. Verify retry
deduplication prevents a second write to stock MTProxy.
4. Add 1%, then 5%, packet loss and 200-500 ms latency. Verify ordered recovery.
5. Restart the web relay while preserving or intentionally discarding session state;
record expected bridge status and MTProto reconnection.
6. Restart stock MTProxy only. Affected logical streams must close/reconnect without
breaking the browser carrier.
7. Return malformed frame length, unknown frame type, invalid stream zero usage,
zero/invalid `WINDOW`, and data beyond granted credit in a controlled staging
environment. The client must close the carrier/streams cleanly, remain responsive,
and show no memory error.
8. Send `BYE`. Current streams must fail and reconnect according to the bridge/server
recovery policy.
9. Race client-side close against backend EOF and delayed `DATA`/`WINDOW`/`CLOSE`.
The closed stream may discard late frames, but unrelated streams must remain live.
10. Stall downlink reads and send highly fragmented one-byte/empty-control patterns.
Verify the relay enforces byte and item budgets, returns at most 4096 frames in
one body, coalesces adjacent credit, and returns near baseline heap use afterward.
11. Issue bridge requests past the per-IP burst/rate and unused-token limits. Other
source IPs must retain bounded access, expired tokens must release their slots,
and the public fallback must not expose why a request was limited.
## 11. Loopback validation tests (P0)
Use a purpose-built local test client; do not paste the real capability into shell
history.
Verify rejection of:
- a connection to a non-loopback interface (the port must not be listening there);
- wrong `Host`;
- absent, wrong, or cross-origin `Origin`;
- duplicate HTTP header names, including `Host` and `Origin`;
- invalid WebSocket key/version/upgrade headers;
- an unmasked client frame;
- invalid fragmentation or control-frame fragmentation;
- a WebSocket message larger than 2 MiB;
- an HTTP header block larger than 16 KiB;
- a GET with `Content-Length` or `Transfer-Encoding`;
- a silent connection or unauthenticated WebSocket held past ten seconds;
- first message not being an auth object;
- wrong, reused, expired, or empty capability;
- binary data before authentication;
- non-status text after authentication.
Then verify:
- a consumed capability cannot authenticate a second socket;
- minting via `Open browser` invalidates any unconsumed earlier capability;
- a newly authenticated browser replaces the old one and forces logical reconnect;
- `GET /` never includes the capability or MTProxy secret;
- the local protocol cannot request an arbitrary host or port;
- malformed input causes bounded close/failure, not a crash or growing buffer.
- the loopback parent CSP contains a fresh nonce and does not permit arbitrary
inline script.
## 12. Persistence and compatibility (P1)
1. Save WEB, quit cleanly, relaunch, and verify hostname/secret/type survive with
port fixed to 443.
2. Switch among System, Disabled, SOCKS5/HTTP/MTProxy, and WEB; verify Qt's global
application proxy is never set to the WEB hostname.
3. Corrupt a copy of the serialized proxy type to an unknown future value and verify
the candidate skips it instead of crashing. Never modify the only real settings
file.
4. Launch an older binary against a disposable copy of settings containing WEB and
document its behavior. The new binary handles unknown types; old binary
behavior may still require a release-note warning.
5. Confirm WEB cannot be shared/copied as a `tg://proxy` link and inactive WEB rows do
not auto-open a browser during availability checks or proxy rotation.
6. Confirm a saved and enabled WEB proxy intentionally opens one replacement tab at
application startup, while closing that tab does not cause an automatic reopen
loop.
## 13. Browser/platform matrix
At minimum execute sections 5, 6, 7 (small traffic), 9, and 11 on each supported
combination available:
| OS | Browser | Version | Result |
|---|---|---|---|
| Windows | Chrome | | |
| Windows | Edge | | |
| Windows | Firefox | | |
| macOS | Chrome | | |
| macOS | Safari | | |
| macOS | Firefox | | |
| all-other desktop | Chrome/Chromium | | |
| all-other desktop | Firefox | | |
Pay special attention to iframe CSP, loopback WebSocket Origin, mixed-content rules,
background-tab throttling, default-browser launch, and managed-browser policies.
## 14. Unreliable-MTProto network field test (P0)
This is the product hypothesis test and cannot be replaced by a lab run.
1. Use a network where direct Telegram and ordinary MTProxy are demonstrably
unreliable.
2. Record those comparison results immediately before WEB testing.
3. Confirm the relay's ordinary site is reachable in the chosen browser.
4. Enable WEB and repeat login/history/message/media cases.
5. Capture sanitized connection ownership and traffic metadata.
6. Confirm the observable external client is the browser and all carrier requests are
ordinary same-origin HTTPS; no public WebSocket is required.
7. Repeat at two times of day and, if possible, through two access providers.
Pass means WEB works while both direct Telegram and ordinary MTProxy controls fail,
without requiring a browser certificate exception or a nonstandard network setting.
## 15. Deployment-boundary checks (P1)
1. Run the Go unit suite and race detector, including concurrent carrier, bootstrap,
queue-fragmentation, downlink-frame-count, and goroutine-shutdown cases.
2. Validate the shipped Caddyfile with the pinned Caddy build and exercise public
root, bridge root, API, static asset, and error routes through Caddy.
3. Confirm `/debug/pprof/` is 404 on the admin listener by default and appears only
when `enable_pprof` is explicitly enabled.
4. Confirm the MTProxy source archive matches the pinned commit checksum and its
Makefile executes as `mtproxy`, not root.
5. From shells running as `caddy` and `tproxy`, verify the MTProxy command line is
hidden by the supplied `/proc` restrictions. Record that root remains able to
inspect the stock upstream `-S` argument.
6. Start sessions with active backend reads and writes, then stop the relay. Shutdown
must complete inside its configured deadline with no backend goroutine left.
## 16. Exit criteria and run report
Attach or link:
- client/server/bridge/MTProxy versions;
- sanitized server headers and logs;
- Debug build result;
- functional and lifecycle checklist;
- transfer hashes and performance/memory table;
- connection-owner evidence;
- long-poll retry/replay evidence;
- loopback and protocol validation results;
- browser/platform matrix;
- unreliable-MTProto network comparisons and outcome;
- every defect with severity, exact reproduction, expected/actual result, timestamps,
and relevant sanitized logs.
Final decision:
| Gate | Result |
|---|---|
| All P0 passed | |
| All P1 passed or explicitly waived | |
| Logs contain no secrets or payloads | |
| Memory and queues bounded | |
| Long-poll reliability proven | |
| Unreliable-MTProto network result confirmed | |
| Ready for wider testing | yes / no |