From 485d0c848ac2be0c2ef484578631e18057f90b6a Mon Sep 17 00:00:00 2001 From: dranik Date: Mon, 4 May 2026 19:16:48 +0300 Subject: [PATCH 1/3] Feat: Add MtProxy (Telegram) --- client/cmake/sources.cmake | 4 + client/core/controllers/coreController.cpp | 9 +- client/core/controllers/coreController.h | 5 + .../selfhosted/installController.cpp | 99 +- .../core/diagnostics/containerDiagnostics.h | 16 + client/core/diagnostics/mtProxyDiagnostics.h | 18 + client/core/installers/installerBase.cpp | 7 + client/core/installers/mtProxyInstaller.cpp | 82 + client/core/installers/mtProxyInstaller.h | 20 + client/core/models/containerConfig.cpp | 10 + client/core/models/containerConfig.h | 3 + client/core/models/protocolConfig.cpp | 9 + client/core/models/protocolConfig.h | 2 + .../protocols/mtProxyProtocolConfig.cpp | 147 ++ .../models/protocols/mtProxyProtocolConfig.h | 38 + client/core/protocols/protocolUtils.cpp | 11 +- client/core/utils/constants/configKeys.h | 1 + .../core/utils/constants/protocolConstants.h | 33 +- client/core/utils/containerEnum.h | 3 +- .../core/utils/containers/containerUtils.cpp | 25 +- client/core/utils/protocolEnum.h | 3 +- .../core/utils/selfhosted/scriptsRegistry.cpp | 55 +- .../core/utils/selfhosted/scriptsRegistry.h | 1 + client/core/utils/selfhosted/sshClient.cpp | 6 +- client/core/utils/selfhosted/sshSession.cpp | 4 +- client/server_scripts/mtproxy/Dockerfile | 9 + .../mtproxy/configure_container.sh | 60 + .../server_scripts/mtproxy/run_container.sh | 9 + client/server_scripts/mtproxy/start.sh | 71 + .../networkReachabilityController.cpp | 46 + .../networkReachabilityController.h | 30 + client/ui/controllers/qml/pageController.h | 1 + .../selfhosted/exportUiController.cpp | 9 + .../selfhosted/exportUiController.h | 1 + .../selfhosted/installUiController.cpp | 198 +- .../selfhosted/installUiController.h | 16 +- client/ui/controllers/serversUiController.cpp | 2 + client/ui/models/containersModel.cpp | 2 + client/ui/models/containersModel.h | 3 +- client/ui/models/protocolsModel.cpp | 3 + client/ui/models/protocolsModel.h | 3 +- .../ui/models/services/mtProxyConfigModel.cpp | 714 +++++++ .../ui/models/services/mtProxyConfigModel.h | 156 ++ .../utils/mtproxy_public_host_input.cpp | 127 ++ .../models/utils/mtproxy_public_host_input.h | 20 + .../Components/SettingsContainersListView.qml | 3 + .../Controls2/ListViewWithRadioButtonType.qml | 3 + .../qml/Pages2/PageServiceMtProxySettings.qml | 1885 +++++++++++++++++ client/ui/qml/Pages2/PageStart.qml | 6 +- client/ui/qml/qml.qrc | 1 + 50 files changed, 3960 insertions(+), 29 deletions(-) create mode 100644 client/core/diagnostics/containerDiagnostics.h create mode 100644 client/core/diagnostics/mtProxyDiagnostics.h create mode 100644 client/core/installers/mtProxyInstaller.cpp create mode 100644 client/core/installers/mtProxyInstaller.h create mode 100644 client/core/models/protocols/mtProxyProtocolConfig.cpp create mode 100644 client/core/models/protocols/mtProxyProtocolConfig.h create mode 100644 client/server_scripts/mtproxy/Dockerfile create mode 100644 client/server_scripts/mtproxy/configure_container.sh create mode 100644 client/server_scripts/mtproxy/run_container.sh create mode 100644 client/server_scripts/mtproxy/start.sh create mode 100644 client/ui/controllers/networkReachabilityController.cpp create mode 100644 client/ui/controllers/networkReachabilityController.h create mode 100644 client/ui/models/services/mtProxyConfigModel.cpp create mode 100644 client/ui/models/services/mtProxyConfigModel.h create mode 100644 client/ui/models/utils/mtproxy_public_host_input.cpp create mode 100644 client/ui/models/utils/mtproxy_public_host_input.h create mode 100644 client/ui/qml/Pages2/PageServiceMtProxySettings.qml diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index 497757757..be96468d4 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -36,6 +36,7 @@ set(HEADERS ${HEADERS} ${CLIENT_ROOT_DIR}/core/installers/torInstaller.h ${CLIENT_ROOT_DIR}/core/installers/sftpInstaller.h ${CLIENT_ROOT_DIR}/core/installers/socks5Installer.h + ${CLIENT_ROOT_DIR}/core/installers/mtProxyInstaller.h ${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.h ${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.h ${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.h @@ -111,6 +112,7 @@ set(SOURCES ${SOURCES} ${CLIENT_ROOT_DIR}/core/installers/torInstaller.cpp ${CLIENT_ROOT_DIR}/core/installers/sftpInstaller.cpp ${CLIENT_ROOT_DIR}/core/installers/socks5Installer.cpp + ${CLIENT_ROOT_DIR}/core/installers/mtProxyInstaller.cpp ${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.cpp @@ -201,12 +203,14 @@ file(GLOB UI_MODELS_H CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/ui/models/*.h ${CLIENT_ROOT_DIR}/ui/models/protocols/*.h ${CLIENT_ROOT_DIR}/ui/models/services/*.h + ${CLIENT_ROOT_DIR}/ui/models/utils/*.h ${CLIENT_ROOT_DIR}/ui/models/api/*.h ) file(GLOB UI_MODELS_CPP CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/ui/models/*.cpp ${CLIENT_ROOT_DIR}/ui/models/protocols/*.cpp ${CLIENT_ROOT_DIR}/ui/models/services/*.cpp + ${CLIENT_ROOT_DIR}/ui/models/utils/*.cpp ${CLIENT_ROOT_DIR}/ui/models/api/*.cpp ) diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 227850b6d..33920196e 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -101,6 +101,9 @@ void CoreController::initModels() m_socks5ConfigModel = new Socks5ProxyConfigModel(this); setQmlContextProperty("Socks5ProxyConfigModel", m_socks5ConfigModel); + m_mtProxyConfigModel = new MtProxyConfigModel(this); + setQmlContextProperty("MtProxyConfigModel", m_mtProxyConfigModel); + m_clientManagementModel = new ClientManagementModel(this); setQmlContextProperty("ClientManagementModel", m_clientManagementModel); @@ -170,7 +173,7 @@ void CoreController::initControllers() #ifdef Q_OS_WINDOWS m_ikev2ConfigModel, #endif - m_sftpConfigModel, m_socks5ConfigModel, this); + m_sftpConfigModel, m_socks5ConfigModel, m_mtProxyConfigModel, this); setQmlContextProperty("InstallController", m_installUiController); m_importController = new ImportUiController(m_importCoreController, this); @@ -203,6 +206,10 @@ void CoreController::initControllers() m_systemController = new SystemController(this); setQmlContextProperty("SystemController", m_systemController); + m_networkReachabilityController = new NetworkReachabilityController(this); + m_engine->rootContext()->setContextProperty("NetworkReachabilityController", m_networkReachabilityController); + m_engine->rootContext()->setContextProperty("NetworkReachability", m_networkReachabilityController); + m_servicesCatalogUiController = new ServicesCatalogUiController(m_servicesCatalogController, m_apiServicesModel, this); setQmlContextProperty("ServicesCatalogUiController", m_servicesCatalogUiController); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 0d77c5167..8100379e5 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -28,6 +28,7 @@ #include "ui/controllers/languageUiController.h" #include "ui/controllers/updateUiController.h" #include "ui/controllers/api/servicesCatalogUiController.h" +#include "ui/controllers/networkReachabilityController.h" #include "core/controllers/serversController.h" #include "core/controllers/selfhosted/usersController.h" @@ -69,6 +70,8 @@ #include "ui/models/serversModel.h" #include "ui/models/services/sftpConfigModel.h" #include "ui/models/services/socks5ProxyConfigModel.h" +#include "ui/models/services/mtProxyConfigModel.h" + #include "ui/models/ipSplitTunnelingModel.h" #include "ui/models/newsModel.h" @@ -158,6 +161,7 @@ private: ServersUiController* m_serversUiController; IpSplitTunnelingUiController* m_ipSplitTunnelingUiController; SystemController* m_systemController; + NetworkReachabilityController* m_networkReachabilityController; AppSplitTunnelingUiController* m_appSplitTunnelingUiController; AllowedDnsUiController* m_allowedDnsUiController; LanguageUiController* m_languageUiController; @@ -210,6 +214,7 @@ private: #endif SftpConfigModel* m_sftpConfigModel; Socks5ProxyConfigModel* m_socks5ConfigModel; + MtProxyConfigModel* m_mtProxyConfigModel; CoreSignalHandlers* m_signalHandlers; }; diff --git a/client/core/controllers/selfhosted/installController.cpp b/client/core/controllers/selfhosted/installController.cpp index c862f4723..a34e55709 100644 --- a/client/core/controllers/selfhosted/installController.cpp +++ b/client/core/controllers/selfhosted/installController.cpp @@ -19,6 +19,7 @@ #include "core/installers/openvpnInstaller.h" #include "core/installers/sftpInstaller.h" #include "core/installers/socks5Installer.h" +#include "core/installers/mtProxyInstaller.h" #include "core/installers/torInstaller.h" #include "core/installers/wireguardInstaller.h" #include "core/installers/xrayInstaller.h" @@ -35,6 +36,7 @@ #include "core/utils/constants/protocolConstants.h" #include "core/models/serverConfig.h" #include "core/models/containerConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" #include "core/models/protocols/awgProtocolConfig.h" #include "ui/models/protocols/wireguardConfigModel.h" #include "core/utils/utilities.h" @@ -54,6 +56,21 @@ using namespace ProtocolUtils; namespace { Logger logger("InstallController"); + + bool dockerDaemonContainerMissing(const QString &out, const QString &containerDockerName) + { + if (!out.contains(QLatin1String("Error response from daemon"), Qt::CaseInsensitive)) { + return false; + } + if (out.contains(QLatin1String("No such container"), Qt::CaseInsensitive) + && out.contains(containerDockerName, Qt::CaseInsensitive)) { + return true; + } + if (out.size() < 700 && out.contains(QLatin1String("is not running"), Qt::CaseInsensitive)) { + return true; + } + return false; + } } InstallController::InstallController(SecureServersRepository *serversRepository, @@ -133,6 +150,11 @@ ErrorCode InstallController::updateContainer(int serverIndex, DockerContainer co ContainerConfig &newConfig) { if (!isUpdateDockerContainerRequired(container, oldConfig, newConfig)) { + if (container == DockerContainer::MtProxy) { + ServerCredentials credentials = m_serversRepository->serverCredentials(serverIndex); + SshSession sshSession(this); + MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } m_serversRepository->setContainerConfig(serverIndex, container, newConfig); return ErrorCode::NoError; } @@ -154,6 +176,9 @@ ErrorCode InstallController::updateContainer(int serverIndex, DockerContainer co } if (errorCode == ErrorCode::NoError) { + if (container == DockerContainer::MtProxy) { + MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } clearCachedProfile(serverIndex, container); m_serversRepository->setContainerConfig(serverIndex, container, newConfig); } @@ -372,9 +397,22 @@ ErrorCode InstallController::configureContainerWorker(const ServerCredentials &c sshSession.replaceVars(amnezia::scriptData(ProtocolScriptType::configure_container, container), baseVars), cbReadStdOut, cbReadStdErr); + if (e != ErrorCode::NoError) { + return e; + } + + if (dockerDaemonContainerMissing(stdOut, ContainerUtils::containerToString(container))) { + qDebug() << "configureContainerWorker: Docker daemon reports container missing/stopped, output:" << stdOut; + return ErrorCode::ServerContainerMissingError; + } + updateContainerConfigAfterInstallation(container, config, stdOut); - return e; + if (container == DockerContainer::MtProxy) { + MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, config); + } + + return ErrorCode::NoError; } ErrorCode InstallController::startupContainerWorker(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config, SshSession &sshSession) @@ -527,6 +565,32 @@ bool InstallController::isReinstallContainerRequired(DockerContainer container, } } + if (container == DockerContainer::MtProxy) { + const auto *oldMt = oldConfig.getMtProxyProtocolConfig(); + const auto *newMt = newConfig.getMtProxyProtocolConfig(); + if (oldMt && newMt) { + const QString oldPort = + oldMt->port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : oldMt->port; + const QString newPort = + newMt->port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : newMt->port; + if (oldPort != newPort) { + return true; + } + const QString oldTransport = oldMt->transportMode.isEmpty() ? QString( + protocols::mtProxy::transportModeStandard) + : oldMt->transportMode; + const QString newTransport = newMt->transportMode.isEmpty() ? QString( + protocols::mtProxy::transportModeStandard) + : newMt->transportMode; + if (oldTransport != newTransport) { + return true; + } + if (oldMt->tlsDomain != newMt->tlsDomain) { + return true; + } + } + } + if (container == DockerContainer::Socks5Proxy) { return true; } @@ -772,6 +836,7 @@ QScopedPointer InstallController::createInstaller(DockerContainer case DockerContainer::TorWebSite: return QScopedPointer(new TorInstaller(this)); case DockerContainer::Sftp: return QScopedPointer(new SftpInstaller(this)); case DockerContainer::Socks5Proxy: return QScopedPointer(new Socks5Installer(this)); + case DockerContainer::MtProxy: return QScopedPointer(new MtProxyInstaller(this)); default: return QScopedPointer(new InstallerBase(this)); } } @@ -810,6 +875,13 @@ bool InstallController::isUpdateDockerContainerRequired(DockerContainer containe return false; } } + } else if (container == DockerContainer::MtProxy) { + const auto *oldMt = oldConfig.getMtProxyProtocolConfig(); + const auto *newMt = newConfig.getMtProxyProtocolConfig(); + if (!oldMt || !newMt) { + return true; + } + return !oldMt->equalsDockerDeploymentSettings(*newMt); } return true; @@ -1093,6 +1165,31 @@ void InstallController::updateContainerConfigAfterInstallation(DockerContainer c onion.replace("\n", ""); torProtocolConfig->serverConfig.site = onion; } + } else if (container == DockerContainer::MtProxy) { + if (auto* mtProxyConfig = containerConfig.getMtProxyProtocolConfig()) { + qDebug() << "amnezia mtproxy" << stdOut; + + static const QRegularExpression reSecret( + QStringLiteral(R"(\[\*\]\s+Secret:\s+([0-9a-fA-F]{32}))"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reTgLink(QStringLiteral(R"(\[\*\]\s+tg://\s+link:\s+(tg://proxy\?[^\s]+))")); + static const QRegularExpression reTmeLink( + QStringLiteral(R"(\[\*\]\s+t\.me\s+link:\s+(https://t\.me/proxy\?[^\s]+))")); + + const QRegularExpressionMatch mSecret = reSecret.match(stdOut); + const QRegularExpressionMatch mTgLink = reTgLink.match(stdOut); + const QRegularExpressionMatch mTmeLink = reTmeLink.match(stdOut); + + if (mSecret.hasMatch()) { + mtProxyConfig->secret = mSecret.captured(1); + } + if (mTgLink.hasMatch()) { + mtProxyConfig->tgLink = mTgLink.captured(1); + } + if (mTmeLink.hasMatch()) { + mtProxyConfig->tmeLink = mTmeLink.captured(1); + } + } } } diff --git a/client/core/diagnostics/containerDiagnostics.h b/client/core/diagnostics/containerDiagnostics.h new file mode 100644 index 000000000..7833cc332 --- /dev/null +++ b/client/core/diagnostics/containerDiagnostics.h @@ -0,0 +1,16 @@ +#ifndef CONTAINERDIAGNOSTICS_H +#define CONTAINERDIAGNOSTICS_H + +namespace amnezia +{ + struct ContainerDiagnostics + { + bool available = false; + bool portReachable = false; + + virtual ~ContainerDiagnostics() = default; + }; + +} // namespace amnezia + +#endif // CONTAINERDIAGNOSTICS_H diff --git a/client/core/diagnostics/mtProxyDiagnostics.h b/client/core/diagnostics/mtProxyDiagnostics.h new file mode 100644 index 000000000..d738a2274 --- /dev/null +++ b/client/core/diagnostics/mtProxyDiagnostics.h @@ -0,0 +1,18 @@ +#ifndef MTPROXYDIAGNOSTICS_H +#define MTPROXYDIAGNOSTICS_H + +#include "containerDiagnostics.h" + +#include + +namespace amnezia { + struct MtProxyDiagnostics : ContainerDiagnostics { + bool upstreamReachable = false; + int clientsConnected = -1; + QString lastConfigRefresh; + QString statsEndpoint; + }; + +} // namespace amnezia + +#endif // MTPROXYDIAGNOSTICS_H diff --git a/client/core/installers/installerBase.cpp b/client/core/installers/installerBase.cpp index 5bf9caf05..d4243a5f4 100644 --- a/client/core/installers/installerBase.cpp +++ b/client/core/installers/installerBase.cpp @@ -14,6 +14,7 @@ #include "core/models/protocols/xrayProtocolConfig.h" #include "core/models/protocols/sftpProtocolConfig.h" #include "core/models/protocols/socks5ProxyProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/torProtocolConfig.h" @@ -91,6 +92,12 @@ ContainerConfig InstallerBase::createBaseConfig(DockerContainer container, int p config.protocolConfig = socks5Config; break; } + case Proto::MtProxy: { + MtProxyProtocolConfig mtConfig; + mtConfig.port = portStr; + config.protocolConfig = mtConfig; + break; + } case Proto::Ikev2: { Ikev2ProtocolConfig ikev2Config; config.protocolConfig = ikev2Config; diff --git a/client/core/installers/mtProxyInstaller.cpp b/client/core/installers/mtProxyInstaller.cpp new file mode 100644 index 000000000..4901df022 --- /dev/null +++ b/client/core/installers/mtProxyInstaller.cpp @@ -0,0 +1,82 @@ +#include "mtProxyInstaller.h" + +#include "core/utils/containerEnum.h" +#include "core/utils/containers/containerUtils.h" +#include "core/utils/protocolEnum.h" +#include "core/utils/selfhosted/sshSession.h" +#include "core/models/containerConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" + +#include +#include +#include +#include + +#include + +using namespace amnezia; + +namespace { + constexpr QLatin1String kMtProxyClientJsonPath("/data/amnezia-mtproxy-client.json"); + constexpr QLatin1String kMtProxyClientJsonUploadPath("data/amnezia-mtproxy-client.json"); + constexpr QLatin1String kMtProxySecretPath("/data/secret"); +} + +MtProxyInstaller::MtProxyInstaller(QObject *parent) + : InstallerBase(parent) { +} + +ErrorCode MtProxyInstaller::extractConfigFromContainer(DockerContainer container, const ServerCredentials &credentials, + SshSession *sshSession, ContainerConfig &config) { + if (container != DockerContainer::MtProxy || !sshSession) { + return ErrorCode::NoError; + } + + MtProxyProtocolConfig *mt = config.getMtProxyProtocolConfig(); + if (!mt) { + return ErrorCode::NoError; + } + + ErrorCode jsonErr = ErrorCode::NoError; + const QByteArray jsonRaw = + sshSession->getTextFileFromContainer(container, credentials, QString(kMtProxyClientJsonPath), jsonErr); + if (jsonErr == ErrorCode::NoError && !jsonRaw.trimmed().isEmpty()) { + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(jsonRaw.trimmed(), &parseError); + if (parseError.error == QJsonParseError::NoError && doc.isObject()) { + QJsonObject merged = mt->toJson(); + const QJsonObject snap = doc.object(); + for (auto it = snap.constBegin(); it != snap.constEnd(); ++it) { + merged.insert(it.key(), it.value()); + } + *mt = MtProxyProtocolConfig::fromJson(merged); + } + } + + ErrorCode secretErr = ErrorCode::NoError; + const QByteArray secretRaw = + sshSession->getTextFileFromContainer(container, credentials, QString(kMtProxySecretPath), secretErr); + const QString sec = QString::fromUtf8(secretRaw).trimmed(); + if (sec.length() == 32) { + static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$")); + if (hex32.match(sec).hasMatch()) { + mt->secret = sec; + } + } + + return ErrorCode::NoError; +} + +void MtProxyInstaller::uploadClientSettingsSnapshot(SshSession &sshSession, const ServerCredentials &credentials, + DockerContainer container, const ContainerConfig &config) { + const MtProxyProtocolConfig *mt = config.getMtProxyProtocolConfig(); + if (!mt) { + return; + } + const QByteArray payload = QJsonDocument(mt->toJson()).toJson(QJsonDocument::Compact); + const ErrorCode err = sshSession.uploadTextFileToContainer(container, credentials, QString::fromUtf8(payload), + QString(kMtProxyClientJsonUploadPath)); + if (err != ErrorCode::NoError) { + qWarning() << "MtProxyInstaller::uploadClientSettingsSnapshot failed" << err; + } +} diff --git a/client/core/installers/mtProxyInstaller.h b/client/core/installers/mtProxyInstaller.h new file mode 100644 index 000000000..88da30bd7 --- /dev/null +++ b/client/core/installers/mtProxyInstaller.h @@ -0,0 +1,20 @@ +#ifndef MTPROXYINSTALLER_H +#define MTPROXYINSTALLER_H + +#include "installerBase.h" + +class MtProxyInstaller : public InstallerBase { +Q_OBJECT +public: + explicit MtProxyInstaller(QObject *parent = nullptr); + + amnezia::ErrorCode + extractConfigFromContainer(amnezia::DockerContainer container, const amnezia::ServerCredentials &credentials, + SshSession *sshSession, amnezia::ContainerConfig &config) override; + + static void uploadClientSettingsSnapshot(SshSession &sshSession, const amnezia::ServerCredentials &credentials, + amnezia::DockerContainer container, + const amnezia::ContainerConfig &config); +}; + +#endif // MTPROXYINSTALLER_H diff --git a/client/core/models/containerConfig.cpp b/client/core/models/containerConfig.cpp index 834f1b540..deb123d45 100644 --- a/client/core/models/containerConfig.cpp +++ b/client/core/models/containerConfig.cpp @@ -113,6 +113,16 @@ const Socks5ProxyProtocolConfig* ContainerConfig::getSocks5ProxyProtocolConfig() return protocolConfig.as(); } +MtProxyProtocolConfig* ContainerConfig::getMtProxyProtocolConfig() +{ + return protocolConfig.as(); +} + +const MtProxyProtocolConfig* ContainerConfig::getMtProxyProtocolConfig() const +{ + return protocolConfig.as(); +} + Ikev2ProtocolConfig* ContainerConfig::getIkev2ProtocolConfig() { return protocolConfig.as(); diff --git a/client/core/models/containerConfig.h b/client/core/models/containerConfig.h index 7f94cce76..9f116fc08 100644 --- a/client/core/models/containerConfig.h +++ b/client/core/models/containerConfig.h @@ -57,6 +57,9 @@ struct ContainerConfig { Socks5ProxyProtocolConfig* getSocks5ProxyProtocolConfig(); const Socks5ProxyProtocolConfig* getSocks5ProxyProtocolConfig() const; + MtProxyProtocolConfig* getMtProxyProtocolConfig(); + const MtProxyProtocolConfig* getMtProxyProtocolConfig() const; + Ikev2ProtocolConfig* getIkev2ProtocolConfig(); const Ikev2ProtocolConfig* getIkev2ProtocolConfig() const; diff --git a/client/core/models/protocolConfig.cpp b/client/core/models/protocolConfig.cpp index ed91f7beb..2b3d60864 100644 --- a/client/core/models/protocolConfig.cpp +++ b/client/core/models/protocolConfig.cpp @@ -9,6 +9,7 @@ #include "core/utils/protocolEnum.h" #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/dnsProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" namespace amnezia { @@ -38,6 +39,8 @@ Proto ProtocolConfig::type() const return Proto::TorWebSite; } else if constexpr (std::is_same_v) { return Proto::Dns; + } else if constexpr (std::is_same_v) { + return Proto::MtProxy; } return Proto::Unknown; }, data); @@ -65,6 +68,8 @@ QString ProtocolConfig::port() const return QString(); } else if constexpr (std::is_same_v) { return QString(); + } else if constexpr (std::is_same_v) { + return arg.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : arg.port; } return QString(); }, data); @@ -88,6 +93,8 @@ QString ProtocolConfig::transportProto() const return QString(); } else if constexpr (std::is_same_v) { return QString(); + } else if constexpr (std::is_same_v) { + return QStringLiteral("tcp"); } return QString(); }, data); @@ -299,6 +306,8 @@ ProtocolConfig ProtocolConfig::fromJson(const QJsonObject& json, Proto type) return ProtocolConfig{TorProtocolConfig::fromJson(json)}; case Proto::Dns: return ProtocolConfig{DnsProtocolConfig::fromJson(json)}; + case Proto::MtProxy: + return ProtocolConfig{MtProxyProtocolConfig::fromJson(json)}; default: return ProtocolConfig{AwgProtocolConfig{}}; } diff --git a/client/core/models/protocolConfig.h b/client/core/models/protocolConfig.h index 325f52ab9..8a8cf91f2 100644 --- a/client/core/models/protocolConfig.h +++ b/client/core/models/protocolConfig.h @@ -22,6 +22,7 @@ #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/torProtocolConfig.h" #include "core/models/protocols/dnsProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" namespace amnezia { @@ -36,6 +37,7 @@ struct ProtocolConfig { XrayProtocolConfig, SftpProtocolConfig, Socks5ProxyProtocolConfig, + MtProxyProtocolConfig, Ikev2ProtocolConfig, TorProtocolConfig, DnsProtocolConfig diff --git a/client/core/models/protocols/mtProxyProtocolConfig.cpp b/client/core/models/protocols/mtProxyProtocolConfig.cpp new file mode 100644 index 000000000..d6e0ce1be --- /dev/null +++ b/client/core/models/protocols/mtProxyProtocolConfig.cpp @@ -0,0 +1,147 @@ +#include "mtProxyProtocolConfig.h" + +#include "../../../core/utils/protocolEnum.h" +#include "../../../core/protocols/protocolUtils.h" +#include "../../../core/utils/constants/configKeys.h" +#include "../../../core/utils/constants/protocolConstants.h" +#include + +#include + +using namespace amnezia; + +namespace amnezia { + + QJsonObject MtProxyProtocolConfig::toJson() const { + QJsonObject obj; + + if (!port.isEmpty()) { + obj[configKey::port] = port; + } + if (!secret.isEmpty()) { + obj[protocols::mtProxy::secretKey] = secret; + } + if (!tag.isEmpty()) { + obj[protocols::mtProxy::tagKey] = tag; + } + if (!tgLink.isEmpty()) { + obj[protocols::mtProxy::tgLinkKey] = tgLink; + } + if (!tmeLink.isEmpty()) { + obj[protocols::mtProxy::tmeLinkKey] = tmeLink; + } + obj[protocols::mtProxy::isEnabledKey] = isEnabled; + if (!publicHost.isEmpty()) { + obj[protocols::mtProxy::publicHostKey] = publicHost; + } + if (!transportMode.isEmpty()) { + obj[protocols::mtProxy::transportModeKey] = transportMode; + } + if (!tlsDomain.isEmpty()) { + obj[protocols::mtProxy::tlsDomainKey] = tlsDomain; + } + if (!additionalSecrets.isEmpty()) { + obj[protocols::mtProxy::additionalSecretsKey] = QJsonArray::fromStringList(additionalSecrets); + } + if (!workersMode.isEmpty()) { + obj[protocols::mtProxy::workersModeKey] = workersMode; + } + if (!workers.isEmpty()) { + obj[protocols::mtProxy::workersKey] = workers; + } + obj[protocols::mtProxy::natEnabledKey] = natEnabled; + if (!natInternalIp.isEmpty()) { + obj[protocols::mtProxy::natInternalIpKey] = natInternalIp; + } + if (!natExternalIp.isEmpty()) { + obj[protocols::mtProxy::natExternalIpKey] = natExternalIp; + } + + return obj; + } + + MtProxyProtocolConfig MtProxyProtocolConfig::fromJson(const QJsonObject &json) { + MtProxyProtocolConfig config; + + config.port = json.value(configKey::port).toString(); + config.secret = json.value(protocols::mtProxy::secretKey).toString(); + config.tag = json.value(protocols::mtProxy::tagKey).toString(); + config.tgLink = json.value(protocols::mtProxy::tgLinkKey).toString(); + config.tmeLink = json.value(protocols::mtProxy::tmeLinkKey).toString(); + config.isEnabled = json.value(protocols::mtProxy::isEnabledKey).toBool(true); + config.publicHost = json.value(protocols::mtProxy::publicHostKey).toString(); + config.transportMode = json.value(protocols::mtProxy::transportModeKey).toString(); + config.tlsDomain = json.value(protocols::mtProxy::tlsDomainKey).toString(); + for (const auto &v: json.value(protocols::mtProxy::additionalSecretsKey).toArray()) { + const QString s = v.toString(); + if (!s.isEmpty()) { + config.additionalSecrets.append(s); + } + } + config.workersMode = json.value(protocols::mtProxy::workersModeKey).toString(); + config.workers = json.value(protocols::mtProxy::workersKey).toString(); + config.natEnabled = json.value(protocols::mtProxy::natEnabledKey).toBool(false); + config.natInternalIp = json.value(protocols::mtProxy::natInternalIpKey).toString(); + config.natExternalIp = json.value(protocols::mtProxy::natExternalIpKey).toString(); + + return config; + } + + bool MtProxyProtocolConfig::equalsDockerDeploymentSettings(const MtProxyProtocolConfig &other) const { + const auto normPort = [](const QString &p) { + return p.isEmpty() ? QString(protocols::mtProxy::defaultPort) : p; + }; + const auto normTransport = [](const QString &t) { + return t.isEmpty() ? QString(protocols::mtProxy::transportModeStandard) : t; + }; + const auto normWorkersMode = [](const QString &m) { + return m.isEmpty() ? QString(protocols::mtProxy::workersModeAuto) : m; + }; + + if (normPort(port) != normPort(other.port)) { + return false; + } + if (normTransport(transportMode) != normTransport(other.transportMode)) { + return false; + } + if (tlsDomain != other.tlsDomain) { + return false; + } + if (secret != other.secret) { + return false; + } + if (tag != other.tag) { + return false; + } + if (publicHost != other.publicHost) { + return false; + } + if (normWorkersMode(workersMode) != normWorkersMode(other.workersMode)) { + return false; + } + if (workers != other.workers) { + return false; + } + if (natEnabled != other.natEnabled) { + return false; + } + if (natInternalIp != other.natInternalIp) { + return false; + } + if (natExternalIp != other.natExternalIp) { + return false; + } + if (isEnabled != other.isEnabled) { + return false; + } + + QStringList aa = additionalSecrets; + QStringList bb = other.additionalSecrets; + aa.removeAll(QString()); + bb.removeAll(QString()); + std::sort(aa.begin(), aa.end()); + std::sort(bb.begin(), bb.end()); + return aa == bb; + } + +} // namespace amnezia diff --git a/client/core/models/protocols/mtProxyProtocolConfig.h b/client/core/models/protocols/mtProxyProtocolConfig.h new file mode 100644 index 000000000..b4f532608 --- /dev/null +++ b/client/core/models/protocols/mtProxyProtocolConfig.h @@ -0,0 +1,38 @@ +#ifndef MTPROXYPROTOCOLCONFIG_H +#define MTPROXYPROTOCOLCONFIG_H + +#include +#include +#include + +namespace amnezia { + + struct MtProxyProtocolConfig { + QString port; + QString secret; + QString tag; + QString tgLink; + QString tmeLink; + bool isEnabled = true; + QString publicHost; + QString transportMode; + QString tlsDomain; + QStringList additionalSecrets; + QString workersMode; + QString workers; + bool natEnabled = false; + QString natInternalIp; + QString natExternalIp; + + QJsonObject toJson() const; + + static MtProxyProtocolConfig fromJson(const QJsonObject &json); + + // Port, transport, TLS, secrets, NAT, workers, isEnabled, additionalSecrets (order-independent). + // Ignores tgLink / tmeLink (derived / display). + bool equalsDockerDeploymentSettings(const MtProxyProtocolConfig &other) const; + }; + +} // namespace amnezia + +#endif // MTPROXYPROTOCOLCONFIG_H diff --git a/client/core/protocols/protocolUtils.cpp b/client/core/protocols/protocolUtils.cpp index 65446e0ec..2f8d10c2b 100644 --- a/client/core/protocols/protocolUtils.cpp +++ b/client/core/protocols/protocolUtils.cpp @@ -68,7 +68,9 @@ QMap ProtocolUtils::protocolHumanNames() { Proto::TorWebSite, "Website in Tor network" }, { Proto::Dns, "DNS Service" }, { Proto::Sftp, QObject::tr("SFTP service") }, - { Proto::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } }; + { Proto::Socks5Proxy, QObject::tr("SOCKS5 proxy server") }, + { Proto::MtProxy, QObject::tr("MTProxy (Telegram)") }, + }; } QMap ProtocolUtils::protocolDescriptions() @@ -92,6 +94,7 @@ ServiceType ProtocolUtils::protocolService(Proto p) case Proto::Dns: return ServiceType::Other; case Proto::Sftp: return ServiceType::Other; case Proto::Socks5Proxy: return ServiceType::Other; + case Proto::MtProxy: return ServiceType::Other; default: return ServiceType::Other; } } @@ -104,6 +107,7 @@ int ProtocolUtils::getPortForInstall(Proto p) case OpenVpn: case Socks5Proxy: return QRandomGenerator::global()->bounded(30000, 50000); + case MtProxy: default: return defaultPort(p); } @@ -123,6 +127,7 @@ int ProtocolUtils::defaultPort(Proto p) case Proto::Dns: return 53; case Proto::Sftp: return 222; case Proto::Socks5Proxy: return 38080; + case Proto::MtProxy: return QString(protocols::mtProxy::defaultPort).toInt(); default: return -1; } } @@ -141,6 +146,7 @@ bool ProtocolUtils::defaultPortChangeable(Proto p) case Proto::Dns: return false; case Proto::Sftp: return true; case Proto::Socks5Proxy: return true; + case Proto::MtProxy: return true; default: return false; } } @@ -161,6 +167,7 @@ TransportProto ProtocolUtils::defaultTransportProto(Proto p) case Proto::Dns: return TransportProto::Udp; case Proto::Sftp: return TransportProto::Tcp; case Proto::Socks5Proxy: return TransportProto::Tcp; + case Proto::MtProxy: return TransportProto::Tcp; default: return TransportProto::Udp; } } @@ -180,6 +187,7 @@ bool ProtocolUtils::defaultTransportProtoChangeable(Proto p) case Proto::Dns: return false; case Proto::Sftp: return false; case Proto::Socks5Proxy: return false; + case Proto::MtProxy: return false; default: return false; } return false; @@ -208,4 +216,3 @@ QString ProtocolUtils::getProtocolVersionString(const QJsonObject &protocolConfi if (version == protocols::awg::awgV1_5) return QObject::tr(" (version 1.5)"); return ""; } - diff --git a/client/core/utils/constants/configKeys.h b/client/core/utils/constants/configKeys.h index 40bc842b1..f47e3eb95 100644 --- a/client/core/utils/constants/configKeys.h +++ b/client/core/utils/constants/configKeys.h @@ -92,6 +92,7 @@ namespace amnezia constexpr QLatin1String xray("xray"); constexpr QLatin1String ssxray("ssxray"); constexpr QLatin1String socks5proxy("socks5proxy"); + constexpr QLatin1String mtproxy("mtproxy"); constexpr QLatin1String splitTunnelSites("splitTunnelSites"); constexpr QLatin1String splitTunnelType("splitTunnelType"); diff --git a/client/core/utils/constants/protocolConstants.h b/client/core/utils/constants/protocolConstants.h index 01e2a151a..0cb471d61 100644 --- a/client/core/utils/constants/protocolConstants.h +++ b/client/core/utils/constants/protocolConstants.h @@ -3,6 +3,7 @@ namespace amnezia { + namespace protocols { @@ -174,9 +175,37 @@ namespace amnezia constexpr char proxyConfigPath[] = "/usr/local/3proxy/conf/3proxy.cfg"; } + namespace mtProxy + { + constexpr char secretKey[] = "mtproxy_secret"; + constexpr char tagKey[] = "mtproxy_tag"; + constexpr char tgLinkKey[] = "mtproxy_tg_link"; + constexpr char tmeLinkKey[] = "mtproxy_tme_link"; + constexpr char isEnabledKey[] = "mtproxy_is_enabled"; + constexpr char publicHostKey[] = "mtproxy_public_host"; + constexpr char transportModeKey[] = "mtproxy_transport_mode"; + constexpr char tlsDomainKey[] = "mtproxy_tls_domain"; + constexpr char additionalSecretsKey[] = "mtproxy_additional_secrets"; + constexpr char workersKey[] = "mtproxy_workers"; + constexpr char workersModeKey[] = "mtproxy_workers_mode"; + constexpr char natEnabledKey[] = "mtproxy_nat_enabled"; + constexpr char natInternalIpKey[] = "mtproxy_nat_internal_ip"; + constexpr char natExternalIpKey[] = "mtproxy_nat_external_ip"; + + constexpr char transportModeStandard[] = "standard"; + constexpr char transportModeFakeTLS[] = "faketls"; + + constexpr char workersModeAuto[] = "auto"; + constexpr char workersModeManual[] = "manual"; + + constexpr char defaultPort[] = "443"; + constexpr char defaultWorkers[] = "2"; + constexpr int maxWorkers = 32; + constexpr int botTagHexLength = 32; + constexpr char defaultTlsDomain[] = "googletagmanager.com"; + } + } // namespace protocols } #endif // PROTOCOLCONSTANTS_H - - diff --git a/client/core/utils/containerEnum.h b/client/core/utils/containerEnum.h index 97398b783..8e4fc33f8 100644 --- a/client/core/utils/containerEnum.h +++ b/client/core/utils/containerEnum.h @@ -23,7 +23,8 @@ namespace amnezia TorWebSite, Dns, Sftp, - Socks5Proxy + Socks5Proxy, + MtProxy, }; Q_ENUM_NS(DockerContainer) } // namespace ContainerEnumNS diff --git a/client/core/utils/containers/containerUtils.cpp b/client/core/utils/containers/containerUtils.cpp index b028dcf5f..cda3353d5 100644 --- a/client/core/utils/containers/containerUtils.cpp +++ b/client/core/utils/containers/containerUtils.cpp @@ -72,7 +72,9 @@ QMap ContainerUtils::containerHumanNames() { DockerContainer::TorWebSite, QObject::tr("Website in Tor network") }, { DockerContainer::Dns, QObject::tr("AmneziaDNS") }, { DockerContainer::Sftp, QObject::tr("SFTP file sharing service") }, - { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } }; + { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") }, + { DockerContainer::MtProxy, QObject::tr("MTProxy (Telegram)") }, + }; } QMap ContainerUtils::containerDescriptions() @@ -102,7 +104,10 @@ QMap ContainerUtils::containerDescriptions() { DockerContainer::Sftp, QObject::tr("Create a file vault on your server to securely store and transfer files.") }, { DockerContainer::Socks5Proxy, - QObject::tr("") } }; + QObject::tr("") }, + { DockerContainer::MtProxy, + QObject::tr("Telegram MTProto proxy server") }, + }; } QMap ContainerUtils::containerDetailedDescriptions() @@ -172,7 +177,12 @@ QMap ContainerUtils::containerDetailedDescriptions() "You will be able to access it using\n FileZilla or other SFTP clients, " "as well as mount the disk on your device to access\n it directly from your device.\n\n" "For more detailed information, you can\n find it in the support section under \"Create SFTP file storage.\" ") }, - { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } + { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") }, + { DockerContainer::MtProxy, + QObject::tr("Telegram MTProto proxy server. " + "Allows Telegram clients to connect through your server " + "using the MTProto protocol. Supports FakeTLS mode for " + "bypassing DPI-based blocking.") }, }; } @@ -197,6 +207,7 @@ Proto ContainerUtils::defaultProtocol(DockerContainer c) case DockerContainer::Dns: return Proto::Dns; case DockerContainer::Sftp: return Proto::Sftp; case DockerContainer::Socks5Proxy: return Proto::Socks5Proxy; + case DockerContainer::MtProxy: return Proto::MtProxy; default: return Proto::Unknown; } } @@ -224,6 +235,7 @@ bool ContainerUtils::isSupportedByCurrentPlatform(DockerContainer c) case DockerContainer::Awg: return true; case DockerContainer::Xray: return true; case DockerContainer::SSXray: return true; + case DockerContainer::MtProxy: return true; default: return false; } @@ -237,7 +249,7 @@ bool ContainerUtils::isSupportedByCurrentPlatform(DockerContainer c) case DockerContainer::Awg: return true; case DockerContainer::Xray: return true; case DockerContainer::SSXray: return true; - return false; + case DockerContainer::MtProxy: return true; default: return false; } @@ -256,6 +268,7 @@ bool ContainerUtils::isSupportedByCurrentPlatform(DockerContainer c) case DockerContainer::Awg: return true; case DockerContainer::Xray: return true; case DockerContainer::SSXray: return true; + case DockerContainer::MtProxy: return true; default: return false; } @@ -318,6 +331,7 @@ bool ContainerUtils::isShareable(DockerContainer container) case DockerContainer::Dns: return false; case DockerContainer::Sftp: return false; case DockerContainer::Socks5Proxy: return false; + case DockerContainer::MtProxy: return false; default: return true; } } @@ -346,8 +360,9 @@ int ContainerUtils::installPageOrder(DockerContainer container) case DockerContainer::Xray: return 3; case DockerContainer::Ipsec: return 7; case DockerContainer::SSXray: return 8; + case DockerContainer::MtProxy: + return 20; default: return 0; } } - diff --git a/client/core/utils/protocolEnum.h b/client/core/utils/protocolEnum.h index 2c5d9663b..5293d3fe2 100644 --- a/client/core/utils/protocolEnum.h +++ b/client/core/utils/protocolEnum.h @@ -30,7 +30,8 @@ namespace amnezia TorWebSite, Dns, Sftp, - Socks5Proxy + Socks5Proxy, + MtProxy, }; Q_ENUM_NS(Proto) diff --git a/client/core/utils/selfhosted/scriptsRegistry.cpp b/client/core/utils/selfhosted/scriptsRegistry.cpp index 3ff409499..a5a54c205 100644 --- a/client/core/utils/selfhosted/scriptsRegistry.cpp +++ b/client/core/utils/selfhosted/scriptsRegistry.cpp @@ -9,7 +9,6 @@ #include "core/utils/containerEnum.h" #include "core/utils/containers/containerUtils.h" #include "core/utils/protocolEnum.h" -#include "core/utils/protocolEnum.h" #include "core/protocols/protocolUtils.h" #include "core/utils/constants/configKeys.h" #include "core/utils/constants/protocolConstants.h" @@ -20,6 +19,7 @@ #include "core/models/protocols/xrayProtocolConfig.h" #include "core/models/protocols/sftpProtocolConfig.h" #include "core/models/protocols/socks5ProxyProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" using namespace amnezia; using namespace ProtocolUtils; @@ -38,6 +38,7 @@ QString amnezia::scriptFolder(amnezia::DockerContainer container) case DockerContainer::Dns: return QLatin1String("dns"); case DockerContainer::Sftp: return QLatin1String("sftp"); case DockerContainer::Socks5Proxy: return QLatin1String("socks5_proxy"); + case DockerContainer::MtProxy: return QLatin1String("mtproxy"); default: return QString(); } } @@ -285,6 +286,55 @@ amnezia::ScriptVars amnezia::genSocks5ProxyVars(const ContainerConfig &container return vars; } +amnezia::ScriptVars amnezia::genMtProxyVars(const ContainerConfig &containerConfig) { + ScriptVars vars; + + if (auto *mtProxyProtocolConfig = containerConfig.getMtProxyProtocolConfig()) { + const MtProxyProtocolConfig &c = *mtProxyProtocolConfig; + + vars.append({{"$MTPROXY_PORT", c.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : c.port}}); + vars.append({{"$MTPROXY_SECRET", c.secret}}); + vars.append({{"$MTPROXY_TAG", c.tag}}); + vars.append({{"$MTPROXY_TRANSPORT_MODE", + c.transportMode.isEmpty() ? QString(protocols::mtProxy::transportModeStandard) + : c.transportMode}}); + + QString tlsDomain = c.tlsDomain; + if (tlsDomain.isEmpty()) { + tlsDomain = QString(protocols::mtProxy::defaultTlsDomain); + } + vars.append({{"$MTPROXY_TLS_DOMAIN", tlsDomain}}); + vars.append({{"$MTPROXY_PUBLIC_HOST", c.publicHost}}); + + QStringList additionalList; + for (const QString &s: c.additionalSecrets) { + if (!s.isEmpty()) { + additionalList << s; + } + } + vars.append({{"$MTPROXY_ADDITIONAL_SECRETS", additionalList.join(QLatin1Char(','))}}); + + const QString workersMode = c.workersMode.isEmpty() ? QString(protocols::mtProxy::workersModeAuto) + : c.workersMode; + QString workers; + if (workersMode == QLatin1String(protocols::mtProxy::workersModeManual)) { + workers = c.workers.isEmpty() ? QString(protocols::mtProxy::defaultWorkers) : c.workers; + } else { + const QString transportMode = + c.transportMode.isEmpty() ? QString(protocols::mtProxy::transportModeStandard) : c.transportMode; + workers = (transportMode == QLatin1String(protocols::mtProxy::transportModeFakeTLS)) ? QStringLiteral("0") + : QStringLiteral("2"); + } + vars.append({{"$MTPROXY_WORKERS", workers}}); + + vars.append({{"$MTPROXY_NAT_ENABLED", c.natEnabled ? QStringLiteral("1") : QStringLiteral("0")}}); + vars.append({{"$MTPROXY_NAT_INTERNAL_IP", c.natInternalIp}}); + vars.append({{"$MTPROXY_NAT_EXTERNAL_IP", c.natExternalIp}}); + } + + return vars; +} + amnezia::ScriptVars amnezia::genProtocolVarsForContainer(DockerContainer container, const ContainerConfig &containerConfig) { ScriptVars vars; @@ -309,6 +359,9 @@ amnezia::ScriptVars amnezia::genProtocolVarsForContainer(DockerContainer contain case Proto::Socks5Proxy: vars.append(genSocks5ProxyVars(containerConfig)); break; + case Proto::MtProxy: + vars.append(genMtProxyVars(containerConfig)); + break; default: break; } diff --git a/client/core/utils/selfhosted/scriptsRegistry.h b/client/core/utils/selfhosted/scriptsRegistry.h index 26bb2f0e9..5789a8c20 100644 --- a/client/core/utils/selfhosted/scriptsRegistry.h +++ b/client/core/utils/selfhosted/scriptsRegistry.h @@ -68,6 +68,7 @@ ScriptVars genWireGuardVars(const ContainerConfig &containerConfig); ScriptVars genAwgVars(const ContainerConfig &containerConfig); ScriptVars genSftpVars(const ContainerConfig &containerConfig); ScriptVars genSocks5ProxyVars(const ContainerConfig &containerConfig); +ScriptVars genMtProxyVars(const ContainerConfig &containerConfig); ScriptVars genProtocolVarsForContainer(DockerContainer container, const ContainerConfig &containerConfig); } diff --git a/client/core/utils/selfhosted/sshClient.cpp b/client/core/utils/selfhosted/sshClient.cpp index e8847a484..5e854ab4b 100644 --- a/client/core/utils/selfhosted/sshClient.cpp +++ b/client/core/utils/selfhosted/sshClient.cpp @@ -56,7 +56,7 @@ namespace libssh { QEventLoop wait; connect(&watcher, &QFutureWatcher::finished, &wait, &QEventLoop::quit); watcher.setFuture(future); - wait.exec(); + wait.exec(QEventLoop::ExcludeUserInputEvents); int connectionResult = watcher.result(); @@ -189,7 +189,7 @@ namespace libssh { QEventLoop wait; QObject::connect(this, &Client::writeToChannelFinished, &wait, &QEventLoop::quit); - wait.exec(); + wait.exec(QEventLoop::ExcludeUserInputEvents); return watcher.result(); } @@ -284,7 +284,7 @@ namespace libssh { QEventLoop wait; QObject::connect(this, &Client::scpFileCopyFinished, &wait, &QEventLoop::quit); - wait.exec(); + wait.exec(QEventLoop::ExcludeUserInputEvents); closeScpSession(); return watcher.result(); diff --git a/client/core/utils/selfhosted/sshSession.cpp b/client/core/utils/selfhosted/sshSession.cpp index 5821ad232..c2360c6d1 100644 --- a/client/core/utils/selfhosted/sshSession.cpp +++ b/client/core/utils/selfhosted/sshSession.cpp @@ -103,8 +103,8 @@ ErrorCode SshSession::runContainerScript(const ServerCredentials &credentials, D if (e) return e; - QString runner = - QString("sudo docker exec -i $CONTAINER_NAME %2 %1 ").arg(fileName, (container == DockerContainer::Socks5Proxy ? "sh" : "bash")); + const bool useSh = container == DockerContainer::Socks5Proxy || container == DockerContainer::MtProxy; + QString runner = QString("sudo docker exec -i $CONTAINER_NAME %2 %1 ").arg(fileName, useSh ? "sh" : "bash"); e = runScript(credentials, replaceVars(runner, amnezia::genBaseVars(credentials, container, QString(), QString())), cbReadStdOut, cbReadStdErr); QString remover = QString("sudo docker exec -i $CONTAINER_NAME rm %1 ").arg(fileName); diff --git a/client/server_scripts/mtproxy/Dockerfile b/client/server_scripts/mtproxy/Dockerfile new file mode 100644 index 000000000..64ace34d3 --- /dev/null +++ b/client/server_scripts/mtproxy/Dockerfile @@ -0,0 +1,9 @@ +FROM amneziavpn/mtproxy:latest + +RUN mkdir -p /opt/amnezia /data +RUN printf '#!/bin/sh\ntail -f /dev/null\n' > /opt/amnezia/start.sh && \ + chmod a+x /opt/amnezia/start.sh + +VOLUME /data +ENTRYPOINT ["/bin/sh", "/opt/amnezia/start.sh"] +CMD [""] diff --git a/client/server_scripts/mtproxy/configure_container.sh b/client/server_scripts/mtproxy/configure_container.sh new file mode 100644 index 000000000..5ba6da11b --- /dev/null +++ b/client/server_scripts/mtproxy/configure_container.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +# Download Telegram config files +curl -s https://core.telegram.org/getProxySecret -o /data/proxy-secret +curl -s https://core.telegram.org/getProxyConfig -o /data/proxy-multi.conf + +# Determine secret: env var -> saved file -> generate new +if [ -n "$MTPROXY_SECRET" ]; then + SECRET="$MTPROXY_SECRET" +elif [ -f /data/secret ]; then + SECRET=$(cat /data/secret) +else + SECRET=$(openssl rand -hex 16) +fi + +# Validate: must be exactly 32 hex chars +echo "$SECRET" | grep -qE '^[0-9a-fA-F]{32}$' || SECRET=$(openssl rand -hex 16) + +# Persist secret for start.sh restarts +echo "$SECRET" > /data/secret + +# Detect external IP +IP=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null) +[ -z "$IP" ] && IP=$(curl -s --max-time 5 https://ifconfig.me 2>/dev/null) +[ -z "$IP" ] && IP=$(curl -s --max-time 5 https://icanhazip.com 2>/dev/null) + +# Use custom public host/domain if provided, otherwise fall back to detected IP +if [ -n "$MTPROXY_PUBLIC_HOST" ]; then + LINK_HOST="$MTPROXY_PUBLIC_HOST" +else + LINK_HOST="$IP" +fi + +PORT=$MTPROXY_PORT + +# Transport mode is substituted by replaceVars — plain variable, no curly braces +TRANSPORT_MODE=$MTPROXY_TRANSPORT_MODE + +PADDED_SECRET="dd${SECRET}" + +if [ "$TRANSPORT_MODE" = "faketls" ] && [ -n "$MTPROXY_TLS_DOMAIN" ]; then + DOMAIN_HEX=$(echo -n "$MTPROXY_TLS_DOMAIN" | od -A n -t x1 | tr -d ' \n') + FAKETLS_SECRET="ee${SECRET}${DOMAIN_HEX}" +else + FAKETLS_SECRET="" +fi + +# Active link secret depends on transport mode +if [ "$TRANSPORT_MODE" = "faketls" ] && [ -n "$FAKETLS_SECRET" ]; then + LINK_SECRET="$FAKETLS_SECRET" +else + LINK_SECRET="$PADDED_SECRET" +fi + +# Output stable markers — parsed by updateContainerConfigAfterInstallation() +echo "[*] MTProxy configuration" +echo "[*] Secret: ${SECRET}" +echo "[*] FakeTLS: ${FAKETLS_SECRET}" +echo "[*] tg:// link: tg://proxy?server=${LINK_HOST}&port=${PORT}&secret=${LINK_SECRET}" +echo "[*] t.me link: https://t.me/proxy?server=${LINK_HOST}&port=${PORT}&secret=${LINK_SECRET}" diff --git a/client/server_scripts/mtproxy/run_container.sh b/client/server_scripts/mtproxy/run_container.sh new file mode 100644 index 000000000..9039ced75 --- /dev/null +++ b/client/server_scripts/mtproxy/run_container.sh @@ -0,0 +1,9 @@ +# Run container +sudo docker run -d \ + --log-driver none \ + --restart always \ + -p $MTPROXY_PORT:$MTPROXY_PORT/tcp \ + -v amnezia-mtproxy-data:/data \ + --name $CONTAINER_NAME \ + $CONTAINER_NAME + diff --git a/client/server_scripts/mtproxy/start.sh b/client/server_scripts/mtproxy/start.sh new file mode 100644 index 000000000..4b8248e7e --- /dev/null +++ b/client/server_scripts/mtproxy/start.sh @@ -0,0 +1,71 @@ +#!/bin/sh + +echo "Container startup" + +# Read persisted secret +SECRET="" +if [ -f /data/secret ]; then + SECRET=$(cat /data/secret) +fi + +if [ -z "$SECRET" ]; then + echo "ERROR: /data/secret not found — run configure_container first" + tail -f /dev/null + exit 1 +fi + +# Build tag argument +TAG_ARG="" +if [ -n "$MTPROXY_TAG" ]; then + TAG_ARG="-P $MTPROXY_TAG" +fi + +# Build domain argument for FakeTLS mode +DOMAIN_ARG="" +if [ "$MTPROXY_TRANSPORT_MODE" = "faketls" ] && [ -n "$MTPROXY_TLS_DOMAIN" ]; then + DOMAIN_ARG="--domain $MTPROXY_TLS_DOMAIN" +fi + +WORKERS=$MTPROXY_WORKERS +STATS_PORT=2398 +LISTEN_PORT=$MTPROXY_PORT + +NAT_FLAG="" +NAT_VALUE="" +if [ "$MTPROXY_NAT_ENABLED" = "1" ] && [ -n "$MTPROXY_NAT_INTERNAL_IP" ] && [ -n "$MTPROXY_NAT_EXTERNAL_IP" ]; then + NAT_FLAG="--nat-info" + NAT_VALUE="$MTPROXY_NAT_INTERNAL_IP:$MTPROXY_NAT_EXTERNAL_IP" +else + INTERNAL_IP=$(hostname -i 2>/dev/null | awk '{print $1}') + EXTERNAL_IP=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null) + [ -z "$EXTERNAL_IP" ] && EXTERNAL_IP=$(curl -s --max-time 5 https://ifconfig.me 2>/dev/null) + + if [ -n "$INTERNAL_IP" ] && [ -n "$EXTERNAL_IP" ] && [ "$INTERNAL_IP" != "$EXTERNAL_IP" ]; then + NAT_FLAG="--nat-info" + NAT_VALUE="${INTERNAL_IP}:${EXTERNAL_IP}" + fi +fi + +# Build additional secrets arguments +ADDITIONAL_SECRETS_ARG="" +if [ -n "$MTPROXY_ADDITIONAL_SECRETS" ]; then + for S in $(echo "$MTPROXY_ADDITIONAL_SECRETS" | tr ',' ' '); do + ADDITIONAL_SECRETS_ARG="$ADDITIONAL_SECRETS_ARG -S $S" + done +fi + +# Start proxy (foreground) +exec mtproto-proxy \ + -u root \ + -p ${STATS_PORT} \ + -H ${LISTEN_PORT} \ + -S ${SECRET} \ + ${ADDITIONAL_SECRETS_ARG} \ + --aes-pwd /data/proxy-secret \ + -M ${WORKERS} \ + -C 60000 \ + --allow-skip-dh \ + ${NAT_FLAG:+${NAT_FLAG} ${NAT_VALUE}} \ + ${TAG_ARG} \ + ${DOMAIN_ARG} \ + /data/proxy-multi.conf diff --git a/client/ui/controllers/networkReachabilityController.cpp b/client/ui/controllers/networkReachabilityController.cpp new file mode 100644 index 000000000..390b506c7 --- /dev/null +++ b/client/ui/controllers/networkReachabilityController.cpp @@ -0,0 +1,46 @@ +#include "networkReachabilityController.h" + +#include + +namespace { + + bool reachabilityAllowsRemoteOperations(QNetworkInformation::Reachability r) { + using R = QNetworkInformation::Reachability; + // Unknown: no backend or not yet determined — do not block UI. + return r == R::Online || r == R::Unknown; + } + +} // namespace + +NetworkReachabilityController::NetworkReachabilityController(QObject *parent) : QObject(parent) { + attachToNetworkInformation(); +} + +bool NetworkReachabilityController::hasInternetAccess() const { + return m_hasInternetAccess; +} + +void NetworkReachabilityController::attachToNetworkInformation() { + if (!QNetworkInformation::loadDefaultBackend()) { + return; + } + QNetworkInformation *ni = QNetworkInformation::instance(); + if (!ni) { + return; + } + const bool initial = reachabilityAllowsRemoteOperations(ni->reachability()); + const bool previous = m_hasInternetAccess; + m_hasInternetAccess = initial; + if (previous != m_hasInternetAccess) { + emit hasInternetAccessChanged(); + } + connect(ni, &QNetworkInformation::reachabilityChanged, this, + [this](QNetworkInformation::Reachability r) { + const bool ok = reachabilityAllowsRemoteOperations(r); + if (ok == m_hasInternetAccess) { + return; + } + m_hasInternetAccess = ok; + emit hasInternetAccessChanged(); + }); +} diff --git a/client/ui/controllers/networkReachabilityController.h b/client/ui/controllers/networkReachabilityController.h new file mode 100644 index 000000000..effa2a88b --- /dev/null +++ b/client/ui/controllers/networkReachabilityController.h @@ -0,0 +1,30 @@ +#ifndef NETWORKREACHABILITYCONTROLLER_H +#define NETWORKREACHABILITYCONTROLLER_H + +#include + +// Exposes QNetworkInformation to QML for UI that must not run remote operations offline. +// Note: mozilla/networkwatcher.h has NetworkWatcher::getReachability() using the same API, +// but networkwatcher.cpp is not linked into the desktop client (only the service process). + +class NetworkReachabilityController final : public QObject { +Q_OBJECT + + Q_PROPERTY(bool hasInternetAccess READ hasInternetAccess NOTIFY hasInternetAccessChanged) + +public: + explicit NetworkReachabilityController(QObject *parent = nullptr); + + bool hasInternetAccess() const; + +signals: + + void hasInternetAccessChanged(); + +private: + void attachToNetworkInformation(); + + bool m_hasInternetAccess = true; +}; + +#endif // NETWORKREACHABILITYCONTROLLER_H diff --git a/client/ui/controllers/qml/pageController.h b/client/ui/controllers/qml/pageController.h index 603e8a8f4..c6349dc9b 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -50,6 +50,7 @@ namespace PageLoader PageServiceTorWebsiteSettings, PageServiceDnsSettings, PageServiceSocksProxySettings, + PageServiceMtProxySettings, PageSetupWizardStart, PageSetupWizardCredentials, diff --git a/client/ui/controllers/selfhosted/exportUiController.cpp b/client/ui/controllers/selfhosted/exportUiController.cpp index 4a9f8bfc4..806e3290c 100644 --- a/client/ui/controllers/selfhosted/exportUiController.cpp +++ b/client/ui/controllers/selfhosted/exportUiController.cpp @@ -1,6 +1,7 @@ #include "exportUiController.h" #include "../systemController.h" +#include "core/utils/qrCodeUtils.h" ExportUiController::ExportUiController(ExportController* exportController, QObject *parent) : QObject(parent), @@ -51,6 +52,14 @@ void ExportUiController::generateXrayConfig(int serverIndex, const QString &clie applyExportResult(result); } +void ExportUiController::generateQrFromString(const QString &text) +{ + clearPreviousConfig(); + m_config = text; + m_qrCodes = qrCodeUtils::generateQrCodeImageSeries(text.toUtf8()); + emit exportConfigChanged(); +} + QString ExportUiController::getConfig() { return m_config; diff --git a/client/ui/controllers/selfhosted/exportUiController.h b/client/ui/controllers/selfhosted/exportUiController.h index bfc000b0e..75eea42a1 100644 --- a/client/ui/controllers/selfhosted/exportUiController.h +++ b/client/ui/controllers/selfhosted/exportUiController.h @@ -23,6 +23,7 @@ public slots: void generateWireGuardConfig(int serverIndex, const QString &clientName); void generateAwgConfig(int serverIndex, int containerIndex, const QString &clientName); void generateXrayConfig(int serverIndex, const QString &clientName); + void generateQrFromString(const QString &text); QString getConfig(); QString getNativeConfigString(); diff --git a/client/ui/controllers/selfhosted/installUiController.cpp b/client/ui/controllers/selfhosted/installUiController.cpp index b867a0f5d..2c57e3d2a 100755 --- a/client/ui/controllers/selfhosted/installUiController.cpp +++ b/client/ui/controllers/selfhosted/installUiController.cpp @@ -5,7 +5,10 @@ #include #include #include +#include #include +#include +#include #include "core/utils/api/apiUtils.h" #include "core/controllers/selfhosted/installController.h" @@ -69,6 +72,7 @@ InstallUiController::InstallUiController(InstallController *installController, #endif SftpConfigModel *sftpConfigModel, Socks5ProxyConfigModel *socks5ConfigModel, + MtProxyConfigModel* mtConfigModel, QObject *parent) : QObject(parent), m_installController(installController), @@ -85,7 +89,8 @@ InstallUiController::InstallUiController(InstallController *installController, m_ikev2ConfigModel(ikev2ConfigModel), #endif m_sftpConfigModel(sftpConfigModel), - m_socks5ConfigModel(socks5ConfigModel) + m_socks5ConfigModel(socks5ConfigModel), + m_mtProxyConfigModel(mtConfigModel) { connect(m_installController, &InstallController::configValidated, this, &InstallUiController::configValidated); connect(m_installController, &InstallController::validationErrorOccurred, this, [this](ErrorCode errorCode) { @@ -202,7 +207,7 @@ void InstallUiController::scanServerForInstalledContainers(int serverIndex) emit installationErrorOccurred(errorCode); } -void InstallUiController::updateContainer(int serverIndex, int containerIndex, int protocolIndex) +void InstallUiController::updateContainer(int serverIndex, int containerIndex, int protocolIndex, bool closePage) { DockerContainer container = static_cast(containerIndex); @@ -241,6 +246,10 @@ void InstallUiController::updateContainer(int serverIndex, int containerIndex, i containerConfig.protocolConfig = m_socks5ConfigModel->getProtocolConfig(); break; } + case Proto::MtProxy: { + containerConfig.protocolConfig = m_mtProxyConfigModel->getProtocolConfig(); + break; + } #ifdef Q_OS_WINDOWS case Proto::Ikev2: { containerConfig.protocolConfig = m_ikev2ConfigModel->getProtocolConfig(); @@ -252,6 +261,45 @@ void InstallUiController::updateContainer(int serverIndex, int containerIndex, i } ContainerConfig oldContainerConfig = m_serversController->getContainerConfig(serverIndex, container); + if (container == DockerContainer::MtProxy) { + emit serverIsBusy(true); + auto *watcher = new QFutureWatcher(this); + QObject::connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, serverIndex, container, closePage]() { + const ErrorCode errorCode = watcher->result(); + watcher->deleteLater(); + emit serverIsBusy(false); + + if (errorCode == ErrorCode::NoError) { + const ContainerConfig updatedConfig = + m_serversController->getContainerConfig(serverIndex, container); + m_protocolModel->updateModel(updatedConfig); + + const auto defaultContainer = + m_serversController->getServerConfig(serverIndex).defaultContainer(); + if ((serverIndex == m_serversController->getDefaultServerIndex()) + && (container == defaultContainer)) { + emit currentContainerUpdated(); + } else { + emit updateContainerFinished(tr("Settings updated successfully"), closePage); + } + } else { + emit installationErrorOccurred(errorCode); + } + }); + + ContainerConfig newConfigCopy = containerConfig; + ContainerConfig oldConfigCopy = oldContainerConfig; + InstallController *installController = m_installController; + QFuture future = + QtConcurrent::run([installController, serverIndex, container, oldConfigCopy, + newConfigCopy]() mutable -> ErrorCode { + return installController->updateContainer(serverIndex, container, oldConfigCopy, newConfigCopy); + }); + watcher->setFuture(future); + return; + } + ErrorCode errorCode = m_installController->updateContainer(serverIndex, container, oldContainerConfig, containerConfig); if (errorCode == ErrorCode::NoError) { @@ -262,7 +310,7 @@ void InstallUiController::updateContainer(int serverIndex, int containerIndex, i if ((serverIndex == m_serversController->getDefaultServerIndex()) && (container == defaultContainer)) { emit currentContainerUpdated(); } else { - emit updateContainerFinished(tr("Settings updated successfully")); + emit updateContainerFinished(tr("Settings updated successfully"), closePage); } return; @@ -271,6 +319,148 @@ void InstallUiController::updateContainer(int serverIndex, int containerIndex, i emit installationErrorOccurred(errorCode); } +void InstallUiController::setContainerEnabled(int serverIndex, int containerIndex, bool enabled) { + const DockerContainer container = static_cast(containerIndex); + const ServerCredentials credentials = m_serversController->getServerCredentials(serverIndex); + const QString containerName = ContainerUtils::containerToString(container); + + emit serverIsBusy(true); + SshSession sshSession(this); + const QString script = enabled + ? QString("sudo docker start %1").arg(containerName) + : QString("sudo docker stop %1").arg(containerName); + const ErrorCode errorCode = sshSession.runScript(credentials, script); + emit serverIsBusy(false); + + if (errorCode == ErrorCode::NoError) { + ContainerConfig currentConfig = m_serversController->getContainerConfig(serverIndex, container); + if (auto *mtConfig = currentConfig.getMtProxyProtocolConfig()) { + mtConfig->isEnabled = enabled; + m_serversController->updateContainerConfig(serverIndex, container, currentConfig); + m_protocolModel->updateModel(currentConfig); + } + emit setContainerEnabledFinished(enabled); + return; + } + + emit installationErrorOccurred(errorCode); +} + +void InstallUiController::refreshContainerStatus(int serverIndex, int containerIndex) { + const DockerContainer container = static_cast(containerIndex); + const ServerCredentials credentials = m_serversController->getServerCredentials(serverIndex); + const QString containerName = ContainerUtils::containerToString(container); + + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + + SshSession sshSession(this); + const QString script = QString( + "sudo docker inspect --format '{{.State.Status}}' %1 2>/dev/null || echo 'not_found'") + .arg(containerName); + const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + emit containerStatusRefreshed(3); + return; + } + + const QString status = stdOut.trimmed(); + if (status == "running") { + emit containerStatusRefreshed(1); + } else if (status == "not_found" || status.isEmpty()) { + emit containerStatusRefreshed(0); + } else if (status == "exited" || status == "created" || status == "paused") { + emit containerStatusRefreshed(2); + } else { + emit containerStatusRefreshed(3); + } +} + +void InstallUiController::refreshContainerDiagnostics(int serverIndex, int containerIndex, int port) { + const ServerCredentials credentials = m_serversController->getServerCredentials(serverIndex); + const DockerContainer container = static_cast(containerIndex); + const QString containerName = ContainerUtils::containerToString(container); + + const QString script = + QString( + "PORT_OK=$(sudo docker exec %1 sh -c 'ss -tlnp 2>/dev/null | grep -q :%2 && echo yes || echo no' 2>/dev/null || echo no); " + "TG_OK=$(curl -s --max-time 5 -o /dev/null -w '%%{http_code}' https://core.telegram.org/getProxySecret 2>/dev/null | grep -q '200' && echo yes || echo no); " + "CLIENTS=$(sudo docker exec amnezia-mtproxy sh -c 'curl -s --max-time 3 http://localhost:2398/stats 2>/dev/null | grep -o \"total_special_connections:[0-9]*\" | cut -d: -f2' 2>/dev/null); " + "CONF_TIME=$(sudo docker exec amnezia-mtproxy sh -c 'stat -c \"%%y\" /data/proxy-multi.conf 2>/dev/null | cut -d. -f1' 2>/dev/null || echo unknown); " + "echo \"PORT_OK=${PORT_OK}\"; " + "echo \"TG_OK=${TG_OK}\"; " + "echo \"CLIENTS=${CLIENTS:-0}\"; " + "echo \"CONF_TIME=${CONF_TIME}\"; " + "echo \"STATS=http://localhost:2398/stats\";") + .arg(containerName) + .arg(port); + + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + + SshSession sshSession(this); + const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + emit containerDiagnosticsRefreshed(false, false, -1, QString(), QString()); + return; + } + + bool portReachable = false; + bool upstreamReachable = false; + int clientsConnected = -1; + QString lastConfigRefresh; + QString statsEndpoint; + + for (const QString &line: stdOut.split('\n', Qt::SkipEmptyParts)) { + if (line.startsWith("PORT_OK=")) { + portReachable = line.mid(8).trimmed() == "yes"; + } else if (line.startsWith("TG_OK=")) { + upstreamReachable = line.mid(6).trimmed() == "yes"; + } else if (line.startsWith("CLIENTS=")) { + clientsConnected = line.mid(8).trimmed().toInt(); + } else if (line.startsWith("CONF_TIME=")) { + lastConfigRefresh = line.mid(10).trimmed(); + } else if (line.startsWith("STATS=")) { + statsEndpoint = line.mid(6).trimmed(); + } + } + + emit containerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, + statsEndpoint); +} + +void InstallUiController::fetchContainerSecret(int serverIndex, int containerIndex) { + const ServerCredentials credentials = m_serversController->getServerCredentials(serverIndex); + const DockerContainer container = static_cast(containerIndex); + const QString containerName = ContainerUtils::containerToString(container); + + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + + SshSession sshSession(this); + const QString path = QStringLiteral("/data/secret"); + const QString cmd = + QStringLiteral("sudo docker exec %1 cat %2").arg(containerName, path); + const ErrorCode errorCode = sshSession.runScript(credentials, cmd, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + emit containerSecretFetched(QString()); + return; + } + + const QString secret = stdOut.trimmed(); + static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$")); + emit containerSecretFetched(hex32.match(secret).hasMatch() ? secret : QString()); +} + void InstallUiController::rebootServer(int serverIndex) { QString serverName = m_serversController->getServerConfig(serverIndex).displayName(); @@ -484,10 +674,10 @@ void InstallUiController::updateProtocolConfigModel(int serverIndex, int contain case Proto::TorWebSite: updateIfPresent(m_torConfigModel, containerConfig.getTorProtocolConfig()); break; case Proto::Sftp: updateIfPresent(m_sftpConfigModel, containerConfig.getSftpProtocolConfig()); break; case Proto::Socks5Proxy: updateIfPresent(m_socks5ConfigModel, containerConfig.getSocks5ProxyProtocolConfig()); break; + case Proto::MtProxy: updateIfPresent(m_mtProxyConfigModel, containerConfig.getMtProxyProtocolConfig()); break; #ifdef Q_OS_WINDOWS case Proto::Ikev2: updateIfPresent(m_ikev2ConfigModel, containerConfig.getIkev2ProtocolConfig()); break; #endif default: break; } } - diff --git a/client/ui/controllers/selfhosted/installUiController.h b/client/ui/controllers/selfhosted/installUiController.h index 89e0291cf..225fb85e5 100644 --- a/client/ui/controllers/selfhosted/installUiController.h +++ b/client/ui/controllers/selfhosted/installUiController.h @@ -28,6 +28,7 @@ #include "ui/models/services/torConfigModel.h" #include "core/models/protocols/sftpProtocolConfig.h" #include "core/models/protocols/socks5ProxyProtocolConfig.h" +#include "ui/models/services/mtProxyConfigModel.h" class InstallUiController : public QObject { @@ -48,6 +49,7 @@ public: #endif SftpConfigModel* sftpConfigModel, Socks5ProxyConfigModel* socks5ConfigModel, + MtProxyConfigModel* mtConfigModel, QObject *parent = nullptr); ~InstallUiController(); @@ -58,12 +60,16 @@ public slots: void scanServerForInstalledContainers(int serverIndex); - void updateContainer(int serverIndex, int containerIndex, int protocolIndex); + void updateContainer(int serverIndex, int containerIndex, int protocolIndex, bool closePage = true); void removeServer(int serverIndex); void rebootServer(int serverIndex); void removeAllContainers(int serverIndex); void removeContainer(int serverIndex, int containerIndex); + void setContainerEnabled(int serverIndex, int containerIndex, bool enabled); + void refreshContainerStatus(int serverIndex, int containerIndex); + void refreshContainerDiagnostics(int serverIndex, int containerIndex, int port); + void fetchContainerSecret(int serverIndex, int containerIndex); void clearCachedProfile(int serverIndex, int containerIndex); @@ -94,7 +100,7 @@ signals: void installContainerFinished(const QString &finishMessage, bool isServiceInstall); void installServerFinished(const QString &finishMessage); - void updateContainerFinished(const QString &message); + void updateContainerFinished(const QString &message, bool closePage); void scanServerFinished(bool isInstalledContainerFound); @@ -102,6 +108,11 @@ signals: void removeServerFinished(const QString &finishedMessage); void removeAllContainersFinished(const QString &finishedMessage); void removeContainerFinished(const QString &finishedMessage); + void setContainerEnabledFinished(bool enabled); + void containerStatusRefreshed(int status); + void containerDiagnosticsRefreshed(bool portReachable, bool upstreamReachable, int clientsConnected, + const QString &lastConfigRefresh, const QString &statsEndpoint); + void containerSecretFetched(const QString &secret); void installationErrorOccurred(ErrorCode errorCode); void wrongInstallationUser(const QString &message); @@ -140,6 +151,7 @@ private: #endif SftpConfigModel* m_sftpConfigModel; Socks5ProxyConfigModel* m_socks5ConfigModel; + MtProxyConfigModel* m_mtProxyConfigModel; ServerCredentials m_processedServerCredentials; diff --git a/client/ui/controllers/serversUiController.cpp b/client/ui/controllers/serversUiController.cpp index ccafe0f4b..5974e6646 100644 --- a/client/ui/controllers/serversUiController.cpp +++ b/client/ui/controllers/serversUiController.cpp @@ -482,6 +482,8 @@ QStringList ServersUiController::getAllInstalledServicesName(int serverIndex) co servicesName.append("TOR"); } else if (container == DockerContainer::Socks5Proxy) { servicesName.append("SOCKS5"); + } else if (container == DockerContainer::MtProxy) { + servicesName.append("MTProxy"); } } } diff --git a/client/ui/models/containersModel.cpp b/client/ui/models/containersModel.cpp index 335ddbe7c..ade74f984 100644 --- a/client/ui/models/containersModel.cpp +++ b/client/ui/models/containersModel.cpp @@ -74,6 +74,7 @@ QVariant ContainersModel::data(const QModelIndex &index, int role) const case IsSftpRole: return container == DockerContainer::Sftp; case IsTorWebsiteRole: return container == DockerContainer::TorWebSite; case IsSocks5ProxyRole: return container == DockerContainer::Socks5Proxy; + case IsMtProxyRole: return container == DockerContainer::MtProxy; case InstallPageOrderRole: return ContainerUtils::installPageOrder(container); } @@ -184,5 +185,6 @@ QHash ContainersModel::roleNames() const roles[IsSftpRole] = "isSftp"; roles[IsTorWebsiteRole] = "isTorWebsite"; roles[IsSocks5ProxyRole] = "isSocks5Proxy"; + roles[IsMtProxyRole] = "isMtProxy"; return roles; } diff --git a/client/ui/models/containersModel.h b/client/ui/models/containersModel.h index e5f71b01d..d88628d91 100644 --- a/client/ui/models/containersModel.h +++ b/client/ui/models/containersModel.h @@ -48,7 +48,8 @@ public: IsDnsRole, IsSftpRole, IsTorWebsiteRole, - IsSocks5ProxyRole + IsSocks5ProxyRole, + IsMtProxyRole, }; Q_INVOKABLE void openContainerSettings(int containerIndex); diff --git a/client/ui/models/protocolsModel.cpp b/client/ui/models/protocolsModel.cpp index 773a92344..c0cbe99ce 100644 --- a/client/ui/models/protocolsModel.cpp +++ b/client/ui/models/protocolsModel.cpp @@ -42,6 +42,7 @@ QHash ProtocolsModel::roleNames() const roles[IsSftpRole] = "isSftp"; roles[IsIpsecRole] = "isIpsec"; roles[IsSocks5ProxyRole] = "isSocks5Proxy"; + roles[IsMtProxyRole] = "isMtProxy"; return roles; } @@ -71,6 +72,7 @@ QVariant ProtocolsModel::data(const QModelIndex &index, int role) const case IsSftpRole: return proto == Proto::Sftp; case IsIpsecRole: return proto == Proto::Ikev2; case IsSocks5ProxyRole: return proto == Proto::Socks5Proxy; + case IsMtProxyRole: return proto == Proto::MtProxy; case RawConfigRole: return getRawConfig(); case IsClientProtocolExistsRole: @@ -124,6 +126,7 @@ PageLoader::PageEnum ProtocolsModel::serverProtocolPage(Proto protocol) const case Proto::Dns: return PageLoader::PageEnum::PageServiceDnsSettings; case Proto::Sftp: return PageLoader::PageEnum::PageServiceSftpSettings; case Proto::Socks5Proxy: return PageLoader::PageEnum::PageServiceSocksProxySettings; + case Proto::MtProxy: return PageLoader::PageEnum::PageServiceMtProxySettings; default: return PageLoader::PageEnum::PageProtocolOpenVpnSettings; } } diff --git a/client/ui/models/protocolsModel.h b/client/ui/models/protocolsModel.h index a10ab8731..01f9b9cd4 100644 --- a/client/ui/models/protocolsModel.h +++ b/client/ui/models/protocolsModel.h @@ -25,7 +25,8 @@ public: IsXrayRole, IsSftpRole, IsIpsecRole, - IsSocks5ProxyRole + IsSocks5ProxyRole, + IsMtProxyRole, }; explicit ProtocolsModel(QObject *parent = nullptr); diff --git a/client/ui/models/services/mtProxyConfigModel.cpp b/client/ui/models/services/mtProxyConfigModel.cpp new file mode 100644 index 000000000..5e68d786a --- /dev/null +++ b/client/ui/models/services/mtProxyConfigModel.cpp @@ -0,0 +1,714 @@ +#include "mtProxyConfigModel.h" + +#include "ui/models/utils/mtproxy_public_host_input.h" + +#include "core/utils/networkUtilities.h" +#include "core/utils/qrCodeUtils.h" +#include "core/utils/constants/protocolConstants.h" +#include "core/utils/constants/configKeys.h" +#include "qrcodegen.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace amnezia; + +MtProxyConfigModel::MtProxyConfigModel(QObject *parent) : QAbstractListModel(parent) { + qmlRegisterType("MtProxyConfig", 1, 0, "PublicHostInputValidator"); +} + +int MtProxyConfigModel::rowCount(const QModelIndex &parent) const { + Q_UNUSED(parent); + return 1; +} + +bool MtProxyConfigModel::setData(const QModelIndex &index, const QVariant &value, int role) { + if (!index.isValid() || index.row() != 0) { + return false; + } + + switch (role) { + case Roles::PortRole: { + m_protocolConfig.port = value.toString(); + break; + } + case Roles::SecretRole: { + m_protocolConfig.secret = value.toString(); + break; + } + case Roles::TagRole: { + const QString tag = sanitizeMtProxyTagFieldText(value.toString()); + if (!isValidMtProxyTag(tag)) { + return false; + } + m_protocolConfig.tag = tag; + break; + } + case Roles::IsEnabledRole: { + m_protocolConfig.isEnabled = value.toBool(); + break; + } + case Roles::PublicHostRole: { + const QString h = value.toString().trimmed(); + if (!isValidPublicHost(h)) { + return false; + } + m_protocolConfig.publicHost = h; + break; + } + case Roles::TransportModeRole: { + m_protocolConfig.transportMode = value.toString(); + break; + } + case Roles::TlsDomainRole: { + const QString d = value.toString().trimmed(); + if (!isValidFakeTlsDomain(d)) { + return false; + } + m_protocolConfig.tlsDomain = d; + break; + } + case Roles::AdditionalSecretsRole: { + m_protocolConfig.additionalSecrets = value.toStringList(); + break; + } + case Roles::WorkersModeRole: { + m_protocolConfig.workersMode = value.toString(); + break; + } + case Roles::WorkersRole: { + m_protocolConfig.workers = value.toString(); + break; + } + case Roles::NatEnabledRole: { + m_protocolConfig.natEnabled = value.toBool(); + break; + } + case Roles::NatInternalIpRole: { + const QString ip = value.toString().trimmed(); + if (!isValidOptionalIpv4(ip)) { + return false; + } + m_protocolConfig.natInternalIp = ip; + break; + } + case Roles::NatExternalIpRole: { + const QString ip = value.toString().trimmed(); + if (!isValidOptionalIpv4(ip)) { + return false; + } + m_protocolConfig.natExternalIp = ip; + break; + } + default: { + return false; + } + } + + emit dataChanged(index, index, QList{role}); + return true; +} + +QVariant MtProxyConfigModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() != 0) { + return QVariant(); + } + + switch (role) { + case Roles::PortRole: { + return m_protocolConfig.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : m_protocolConfig.port; + } + case Roles::SecretRole: { + return m_protocolConfig.secret; + } + case Roles::TagRole: { + return m_protocolConfig.tag; + } + case Roles::TgLinkRole: { + return m_protocolConfig.tgLink; + } + case Roles::TmeLinkRole: { + return m_protocolConfig.tmeLink; + } + case Roles::IsEnabledRole: { + return m_protocolConfig.isEnabled; + } + case Roles::PublicHostRole: { + return m_protocolConfig.publicHost.isEmpty() + ? m_fullConfig.value(configKey::hostName).toString() + : m_protocolConfig.publicHost; + } + case Roles::TransportModeRole: { + return m_protocolConfig.transportMode.isEmpty() + ? QString(protocols::mtProxy::transportModeStandard) + : m_protocolConfig.transportMode; + } + case Roles::TlsDomainRole: { + return m_protocolConfig.tlsDomain; + } + case Roles::AdditionalSecretsRole: { + return m_protocolConfig.additionalSecrets; + } + case Roles::WorkersModeRole: { + return m_protocolConfig.workersMode.isEmpty() + ? QString(protocols::mtProxy::workersModeAuto) + : m_protocolConfig.workersMode; + } + case Roles::WorkersRole: { + return m_protocolConfig.workers.isEmpty() ? QString(protocols::mtProxy::defaultWorkers) + : m_protocolConfig.workers; + } + case Roles::NatEnabledRole: { + return m_protocolConfig.natEnabled; + } + case Roles::NatInternalIpRole: { + return m_protocolConfig.natInternalIp; + } + case Roles::NatExternalIpRole: { + return m_protocolConfig.natExternalIp; + } + } + + + return QVariant(); +} + +void MtProxyConfigModel::updateModel(amnezia::DockerContainer container, + const amnezia::MtProxyProtocolConfig &protocolConfig) { + beginResetModel(); + m_container = container; + m_protocolConfig = protocolConfig; + endResetModel(); +} + +void MtProxyConfigModel::updateModel(const QJsonObject &config) { + beginResetModel(); + + m_fullConfig = config; + m_protocolConfig = MtProxyProtocolConfig::fromJson(config.value(configKey::mtproxy).toObject()); + if (m_protocolConfig.port.isEmpty()) m_protocolConfig.port = protocols::mtProxy::defaultPort; + if (m_protocolConfig.transportMode.isEmpty()) m_protocolConfig.transportMode = protocols::mtProxy::transportModeStandard; + if (m_protocolConfig.workersMode.isEmpty()) m_protocolConfig.workersMode = protocols::mtProxy::workersModeAuto; + if (m_protocolConfig.workers.isEmpty()) m_protocolConfig.workers = protocols::mtProxy::defaultWorkers; + { + QString tagIn = sanitizeMtProxyTagFieldText(m_protocolConfig.tag); + if (!isValidMtProxyTag(tagIn)) { + tagIn.clear(); + } + m_protocolConfig.tag = tagIn; + } + + endResetModel(); +} + +QJsonObject MtProxyConfigModel::getConfig() { + m_fullConfig.insert(configKey::mtproxy, m_protocolConfig.toJson()); + return m_fullConfig; +} + +void MtProxyConfigModel::generateSecret() { + // Generate 16 random bytes = 32 hex chars + QString secret; + for (int i = 0; i < 16; ++i) { + quint32 byte = QRandomGenerator::global()->bounded(256); + secret += QString("%1").arg(byte, 2, 16, QChar('0')); + } + + m_protocolConfig.secret = secret; + emit dataChanged(index(0), index(0), QList{SecretRole}); +} + +void MtProxyConfigModel::setSecret(const QString &secret) { + if (secret.isEmpty()) { + return; + } + setData(index(0), secret, SecretRole); +} + +bool MtProxyConfigModel::validateAndSetSecret(const QString &rawSecret) { + if (!QRegularExpression("^[0-9a-fA-F]{32}$").match(rawSecret).hasMatch()) { + return false; + } + setData(index(0), rawSecret, SecretRole); + return true; +} + +void MtProxyConfigModel::setPort(const QString &port) { + setData(index(0), port, PortRole); +} + +void MtProxyConfigModel::setTag(const QString &tag) { + setData(index(0), tag, TagRole); +} + +void MtProxyConfigModel::setPublicHost(const QString &host) { + const QString t = host.trimmed(); + if (!isValidPublicHost(t)) { + return; + } + setData(index(0), t, PublicHostRole); +} + +void MtProxyConfigModel::setTransportMode(const QString &mode) { + setData(index(0), mode, TransportModeRole); +} + +QString MtProxyConfigModel::getTransportMode() const { + return m_protocolConfig.transportMode.isEmpty() + ? QString(protocols::mtProxy::transportModeStandard) + : m_protocolConfig.transportMode; +} + +QString MtProxyConfigModel::getTlsDomain() const { + return m_protocolConfig.tlsDomain.isEmpty() + ? QString(protocols::mtProxy::defaultTlsDomain) + : m_protocolConfig.tlsDomain; +} + +QString MtProxyConfigModel::getPublicHost() const { + return m_protocolConfig.publicHost; +} + +void MtProxyConfigModel::setTlsDomain(const QString &domain) { + const QString t = domain.trimmed(); + if (!isValidFakeTlsDomain(t)) { + return; + } + setData(index(0), t, TlsDomainRole); +} + +void MtProxyConfigModel::setWorkersMode(const QString &mode) { + setData(index(0), mode, WorkersModeRole); +} + +void MtProxyConfigModel::setWorkers(const QString &workers) { + setData(index(0), workers, WorkersRole); +} + +void MtProxyConfigModel::setNatEnabled(bool enabled) { + setData(index(0), enabled, NatEnabledRole); +} + +void MtProxyConfigModel::setNatInternalIp(const QString &ip) { + const QString t = ip.trimmed(); + if (!isValidOptionalIpv4(t)) { + return; + } + setData(index(0), t, NatInternalIpRole); +} + +void MtProxyConfigModel::setNatExternalIp(const QString &ip) { + const QString t = ip.trimmed(); + if (!isValidOptionalIpv4(t)) { + return; + } + setData(index(0), t, NatExternalIpRole); +} + +void MtProxyConfigModel::addAdditionalSecret() { + QString newSecret; + for (int i = 0; i < 16; ++i) { + quint32 byte = QRandomGenerator::global()->bounded(256); + newSecret += QString("%1").arg(byte, 2, 16, QChar('0')); + } + + m_protocolConfig.additionalSecrets.append(newSecret); + emit dataChanged(index(0), index(0), QList{AdditionalSecretsRole}); +} + +void MtProxyConfigModel::removeAdditionalSecret(int idx) { + if (idx < 0 || idx >= m_protocolConfig.additionalSecrets.size()) { + return; + } + m_protocolConfig.additionalSecrets.removeAt(idx); + emit dataChanged(index(0), index(0), QList{AdditionalSecretsRole}); +} + +QVariantList MtProxyConfigModel::additionalSecretsList() const { + QVariantList out; + out.reserve(m_protocolConfig.additionalSecrets.size()); + for (const auto &s: m_protocolConfig.additionalSecrets) { + if (!s.isEmpty()) { + out.append(s); + } + } + return out; +} + +void MtProxyConfigModel::setEnabled(bool enabled) { + m_protocolConfig.isEnabled = enabled; + emit dataChanged(index(0), index(0), QList{IsEnabledRole}); +} + +QString MtProxyConfigModel::generateQrCode(const QString &text) { + if (text.isEmpty()) { + return ""; + } + auto qr = qrCodeUtils::generateQrCode(text.toUtf8()); + return qrCodeUtils::svgToBase64(QString::fromStdString(toSvgString(qr, 1))); +} + +QString MtProxyConfigModel::defaultTlsDomain() const { + return protocols::mtProxy::defaultTlsDomain; +} + +QString MtProxyConfigModel::defaultPort() const { + return protocols::mtProxy::defaultPort; +} + +QString MtProxyConfigModel::defaultWorkers() const { + return protocols::mtProxy::defaultWorkers; +} + +int MtProxyConfigModel::maxWorkers() const { + return protocols::mtProxy::maxWorkers; +} + +QString MtProxyConfigModel::transportModeStandard() const { + return protocols::mtProxy::transportModeStandard; +} + +QString MtProxyConfigModel::transportModeFakeTLS() const { + return protocols::mtProxy::transportModeFakeTLS; +} + +QString MtProxyConfigModel::workersModeAuto() const { + return protocols::mtProxy::workersModeAuto; +} + +QString MtProxyConfigModel::workersModeManual() const { + return protocols::mtProxy::workersModeManual; +} + +bool MtProxyConfigModel::isValidPublicHost(const QString &host) const { + const QString t = host.trimmed(); + if (t.isEmpty()) { + return true; + } + if (t.length() > 253) { + return false; + } + QHostAddress a(t); + if (a.protocol() == QHostAddress::IPv4Protocol) { + return NetworkUtilities::checkIPv4Format(t); + } + if (a.protocol() == QHostAddress::IPv6Protocol) { + return true; + } + static const QRegularExpression onlyAsciiDigits(QStringLiteral(R"(^\d+$)")); + if (onlyAsciiDigits.match(t).hasMatch()) { + return false; + } + return NetworkUtilities::domainRegExp().exactMatch(t); +} + +bool MtProxyConfigModel::isPublicHostInputAllowed(const QString &text) const { + return mtproxyPublicHostInputAllowed(text); +} + +bool MtProxyConfigModel::isPublicHostTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (isValidPublicHost(t)) { + return false; + } + + static const QRegularExpression onlyDigitDot(QStringLiteral(R"(^[0-9.]+$)")); + if (onlyDigitDot.match(t).hasMatch()) { + if (t.endsWith(QLatin1Char('.'))) { + return true; + } + const QStringList parts = t.split(QLatin1Char('.'), Qt::KeepEmptyParts); + if (parts.size() < 4) { + return true; + } + for (const QString &part: parts) { + if (part.isEmpty()) { + return true; + } + } + return false; + } + + if (t.contains(QLatin1Char(':'))) { + if (t.contains(QLatin1String(":::"))) { + return false; + } + if (t.endsWith(QLatin1Char(':'))) { + return true; + } + QHostAddress a(t); + if (a.protocol() == QHostAddress::IPv6Protocol) { + return false; + } + if (!t.contains(QLatin1String("::")) && t.count(QLatin1Char(':')) < 7 && !t.contains(QLatin1Char('.'))) { + return true; + } + return false; + } + + if (!t.contains(QLatin1Char('.'))) { + return true; + } + + return false; +} + +bool MtProxyConfigModel::isValidMtProxyTag(const QString &tag) const { + if (tag.isEmpty()) { + return true; + } + static const QRegularExpression re( + QStringLiteral("^([0-9a-fA-F]{%1})$").arg(protocols::mtProxy::botTagHexLength)); + return re.match(tag).hasMatch(); +} + +bool MtProxyConfigModel::isMtProxyTagTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (t.isEmpty()) { + return true; + } + static const QRegularExpression hexOnly(QStringLiteral(R"(^[0-9a-fA-F]*$)")); + if (!hexOnly.match(t).hasMatch()) { + return false; + } + return t.size() < protocols::mtProxy::botTagHexLength; +} + +int MtProxyConfigModel::mtProxyBotTagHexLength() const { + return protocols::mtProxy::botTagHexLength; +} + +bool MtProxyConfigModel::isValidFakeTlsDomain(const QString &domain) const { + const QString t = domain.trimmed(); + if (t.isEmpty()) { + return true; + } + if (t.length() > 253) { + return false; + } + QHostAddress addr; + if (addr.setAddress(t)) { + return false; + } + static const QRegularExpression onlyAsciiDigits(QStringLiteral(R"(^\d+$)")); + if (onlyAsciiDigits.match(t).hasMatch()) { + return false; + } + QRegExp re(NetworkUtilities::domainRegExp()); + re.setCaseSensitivity(Qt::CaseInsensitive); + if (!re.exactMatch(t)) { + return false; + } + // ee + 32 hex (base secret) + hex(UTF-8 domain); keep headroom under typical client limits. + if (t.toUtf8().size() > 111) { + return false; + } + return true; +} + +QString MtProxyConfigModel::clipboardText() const { + if (QClipboard *c = QGuiApplication::clipboard()) { + return c->text(); + } + return QString(); +} + +QString MtProxyConfigModel::sanitizeFakeTlsDomainFieldText(const QString &input) const { + const QString t = normalizeFakeTlsDomainInput(input); + QString out; + out.reserve(t.size()); + for (const QChar &c: t) { + const ushort u = c.unicode(); + const bool letter = (u >= 'a' && u <= 'z') || (u >= 'A' && u <= 'Z'); + const bool digit = (u >= '0' && u <= '9'); + if (letter || digit || u == '.' || u == '-') { + out.append(c); + } + } + if (out.size() > 253) { + out.truncate(253); + } + return out; +} + +bool MtProxyConfigModel::isFakeTlsDomainInputAllowed(const QString &text) const { + if (text.length() > 253) { + return false; + } + static const QRegularExpression re(QStringLiteral(R"(^[a-zA-Z0-9.-]*$)")); + return re.match(text).hasMatch(); +} + +QString MtProxyConfigModel::sanitizePublicHostFieldText(const QString &input) const { + QString out; + const int cap = qMin(input.size(), 253); + out.reserve(cap); + for (const QChar &c: input) { + if (out.size() >= 253) { + break; + } + const ushort u = c.unicode(); + if ((u >= 'a' && u <= 'z') || (u >= 'A' && u <= 'Z') || (u >= '0' && u <= '9') || u == '.' || u == ':' || + u == '-') { + out.append(c); + } + } + return out; +} + +QString MtProxyConfigModel::sanitizePortFieldText(const QString &input) const { + QString out; + out.reserve(qMin(input.size(), 5)); + for (const QChar &c: input) { + const ushort u = c.unicode(); + if (u >= '0' && u <= '9' && out.size() < 5) { + out.append(c); + } + } + return out; +} + +QString MtProxyConfigModel::sanitizeMtProxyTagFieldText(const QString &input) const { + QString trimmed = input.trimmed(); + if (trimmed.startsWith(QLatin1String("0x"), Qt::CaseInsensitive)) { + trimmed = trimmed.mid(2).trimmed(); + } + // Prefer a contiguous 32-hex run (paste from bot message with extra text). + static const QRegularExpression runHex(QStringLiteral(R"(([0-9a-fA-F]{32}))")); + const QRegularExpressionMatch m = runHex.match(trimmed); + if (m.hasMatch()) { + return m.captured(1); + } + const int cap = protocols::mtProxy::botTagHexLength; + QString out; + out.reserve(qMin(trimmed.size(), cap)); + for (const QChar &c: trimmed) { + if (out.size() >= cap) { + break; + } + const ushort u = c.unicode(); + if ((u >= '0' && u <= '9') || (u >= 'a' && u <= 'f') || (u >= 'A' && u <= 'F')) { + out.append(c); + } + } + return out; +} + +QString MtProxyConfigModel::sanitizeWorkersFieldText(const QString &input) const { + QString out; + out.reserve(qMin(input.size(), 3)); + for (const QChar &c: input) { + const ushort u = c.unicode(); + if (u >= '0' && u <= '9' && out.size() < 3) { + out.append(c); + } + } + return out; +} + +QString MtProxyConfigModel::sanitizeOptionalIpv4FieldText(const QString &input) const { + QString out; + out.reserve(qMin(input.size(), 15)); + for (const QChar &c: input) { + if (out.size() >= 15) { + break; + } + const ushort u = c.unicode(); + if ((u >= '0' && u <= '9') || u == '.') { + out.append(c); + } + } + return out; +} + +QString MtProxyConfigModel::normalizeFakeTlsDomainInput(const QString &input) const { + QString t = input.trimmed(); + if (t.startsWith(QLatin1String("https://"), Qt::CaseInsensitive)) { + t = t.mid(8); + } else if (t.startsWith(QLatin1String("http://"), Qt::CaseInsensitive)) { + t = t.mid(7); + } + if (const int slash = t.indexOf(QLatin1Char('/')); slash >= 0) { + t = t.left(slash); + } + if (const int at = t.indexOf(QLatin1Char('@')); at >= 0) { + t = t.mid(at + 1); + } + if (const int colon = t.indexOf(QLatin1Char(':')); colon >= 0) { + t = t.left(colon); + } + if (t.startsWith(QLatin1String("www."), Qt::CaseInsensitive)) { + const QString rest = t.mid(4); + if (rest.contains(QLatin1Char('.'))) { + t = rest; + } + } + return t.trimmed(); +} + +bool MtProxyConfigModel::isFakeTlsDomainTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (t.isEmpty()) { + return true; + } + if (isValidFakeTlsDomain(t)) { + return false; + } + if (t.contains(QLatin1Char('/')) || t.contains(QLatin1Char(':')) || t.contains(QLatin1Char('@')) + || t.contains(QLatin1Char(' '))) { + return false; + } + if (t.contains(QLatin1String(".."))) { + return false; + } + if (!t.contains(QLatin1Char('.'))) { + return true; + } + if (t.endsWith(QLatin1Char('.'))) { + return true; + } + static const QRegularExpression legalPartial(QStringLiteral(R"(^[a-zA-Z0-9.-]*$)")); + if (!legalPartial.match(t).hasMatch()) { + return false; + } + return true; +} + +bool MtProxyConfigModel::isValidOptionalIpv4(const QString &ip) const { + const QString t = ip.trimmed(); + if (t.isEmpty()) { + return true; + } + return NetworkUtilities::checkIPv4Format(t); +} + +QHash MtProxyConfigModel::roleNames() const { + QHash roles; + + roles[PortRole] = "port"; + roles[SecretRole] = "secret"; + roles[TagRole] = "tag"; + roles[TgLinkRole] = "tgLink"; + roles[TmeLinkRole] = "tmeLink"; + roles[IsEnabledRole] = "isEnabled"; + roles[PublicHostRole] = "publicHost"; + roles[TransportModeRole] = "transportMode"; + roles[TlsDomainRole] = "tlsDomain"; + roles[AdditionalSecretsRole] = "additionalSecrets"; + roles[WorkersModeRole] = "workersMode"; + roles[WorkersRole] = "workers"; + roles[NatEnabledRole] = "natEnabled"; + roles[NatInternalIpRole] = "natInternalIp"; + roles[NatExternalIpRole] = "natExternalIp"; + + return roles; +} + +amnezia::MtProxyProtocolConfig MtProxyConfigModel::getProtocolConfig() { + return m_protocolConfig; +} diff --git a/client/ui/models/services/mtProxyConfigModel.h b/client/ui/models/services/mtProxyConfigModel.h new file mode 100644 index 000000000..b67969ed4 --- /dev/null +++ b/client/ui/models/services/mtProxyConfigModel.h @@ -0,0 +1,156 @@ +#ifndef MTPROXYCONFIGMODEL_H +#define MTPROXYCONFIGMODEL_H + +#include +#include +#include +#include +#include "core/utils/containerEnum.h" +#include "core/utils/containers/containerUtils.h" +#include "core/utils/protocolEnum.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" + +class MtProxyConfigModel : public QAbstractListModel { +Q_OBJECT + +public: + enum Roles { + PortRole = Qt::UserRole + 1, + SecretRole, + TagRole, + TgLinkRole, + TmeLinkRole, + IsEnabledRole, + PublicHostRole, + TransportModeRole, + TlsDomainRole, + AdditionalSecretsRole, + WorkersModeRole, + WorkersRole, + NatEnabledRole, + NatInternalIpRole, + NatExternalIpRole + }; + + explicit MtProxyConfigModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + bool setData(const QModelIndex &index, const QVariant &value, int role) override; + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + + void updateModel(amnezia::DockerContainer container, const amnezia::MtProxyProtocolConfig &protocolConfig); + + void updateModel(const QJsonObject &config); + + QJsonObject getConfig(); + + amnezia::MtProxyProtocolConfig getProtocolConfig(); + + Q_INVOKABLE void generateSecret(); + + Q_INVOKABLE void setSecret(const QString &secret); + + Q_INVOKABLE bool validateAndSetSecret(const QString &rawSecret); + + Q_INVOKABLE void setPort(const QString &port); + + Q_INVOKABLE void setTag(const QString &tag); + + Q_INVOKABLE void setPublicHost(const QString &host); + + Q_INVOKABLE void setTransportMode(const QString &mode); + + Q_INVOKABLE QString getTransportMode() const; + + Q_INVOKABLE QString getTlsDomain() const; + + Q_INVOKABLE QString getPublicHost() const; + + Q_INVOKABLE void setTlsDomain(const QString &domain); + + Q_INVOKABLE void setWorkersMode(const QString &mode); + + Q_INVOKABLE void setWorkers(const QString &workers); + + Q_INVOKABLE void setNatEnabled(bool enabled); + + Q_INVOKABLE void setNatInternalIp(const QString &ip); + + Q_INVOKABLE void setNatExternalIp(const QString &ip); + + Q_INVOKABLE void addAdditionalSecret(); + + Q_INVOKABLE void removeAdditionalSecret(int idx); + /// Current `mtproxy_additional_secrets` list from in-memory config (for QML snapshot vs. unsaved adds). + Q_INVOKABLE QVariantList additionalSecretsList() const; + + Q_INVOKABLE QString generateQrCode(const QString &text); + + Q_INVOKABLE void setEnabled(bool enabled); + + Q_INVOKABLE QString defaultTlsDomain() const; + + Q_INVOKABLE QString defaultPort() const; + + Q_INVOKABLE QString defaultWorkers() const; + + Q_INVOKABLE int maxWorkers() const; + + Q_INVOKABLE QString transportModeStandard() const; + + Q_INVOKABLE QString transportModeFakeTLS() const; + + Q_INVOKABLE QString workersModeAuto() const; + + Q_INVOKABLE QString workersModeManual() const; + + Q_INVOKABLE bool isValidPublicHost(const QString &host) const; + + Q_INVOKABLE bool isPublicHostInputAllowed(const QString &text) const; + + Q_INVOKABLE bool isPublicHostTypingIncomplete(const QString &text) const; + + Q_INVOKABLE bool isValidMtProxyTag(const QString &tag) const; + + Q_INVOKABLE bool isMtProxyTagTypingIncomplete(const QString &text) const; + + Q_INVOKABLE int mtProxyBotTagHexLength() const; + + Q_INVOKABLE bool isValidFakeTlsDomain(const QString &domain) const; + + Q_INVOKABLE QString normalizeFakeTlsDomainInput(const QString &input) const; + + Q_INVOKABLE QString sanitizeFakeTlsDomainFieldText(const QString &input) const; + + Q_INVOKABLE bool isFakeTlsDomainInputAllowed(const QString &text) const; + + Q_INVOKABLE QString clipboardText() const; + + Q_INVOKABLE QString sanitizePublicHostFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizePortFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizeMtProxyTagFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizeWorkersFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizeOptionalIpv4FieldText(const QString &input) const; + + Q_INVOKABLE bool isFakeTlsDomainTypingIncomplete(const QString &text) const; + + Q_INVOKABLE bool isValidOptionalIpv4(const QString &ip) const; + +protected: + QHash roleNames() const override; + +private: + amnezia::DockerContainer m_container; + QJsonObject m_fullConfig; + amnezia::MtProxyProtocolConfig m_protocolConfig; +}; + +#endif // MTPROXYCONFIGMODEL_H diff --git a/client/ui/models/utils/mtproxy_public_host_input.cpp b/client/ui/models/utils/mtproxy_public_host_input.cpp new file mode 100644 index 000000000..2cbf0b2f7 --- /dev/null +++ b/client/ui/models/utils/mtproxy_public_host_input.cpp @@ -0,0 +1,127 @@ +#include "mtproxy_public_host_input.h" + +#include + +namespace { + + bool ipv4OctetTokenOk(const QString &s) { + static const QRegularExpression re(QStringLiteral(R"(^\d{1,3}$)")); + if (!re.match(s).hasMatch()) { + return false; + } + bool ok = false; + const int n = s.toInt(&ok); + return ok && n >= 0 && n <= 255; + } + +// Reject labels like "312edweqwe" (digits >255 then letters). + bool labelHasInvalidOctetLikePrefixBeforeLetters(const QString &label) { + static const QRegularExpression re(QStringLiteral(R"(^(\d+)([a-zA-Z].*)$)")); + const QRegularExpressionMatch m = re.match(label); + if (!m.hasMatch()) { + return false; + } + const QString digits = m.captured(1); + if (digits.length() > 3) { + return true; + } + bool ok = false; + const int n = digits.toInt(&ok); + if (!ok) { + return true; + } + if (n > 255) { + return true; + } + // Do not restrict n≤255 + letters here (e.g. "123mlkjh.example.com"); four-segment IPv4+junk is handled below. + return false; + } + +// "123.123wqqweqweqweqwe" — first label is a real octet, second looks like an octet glued to letters (not "123.45"). + bool looksLikeTwoSegmentOctetThenDigitLetterGlue(const QString &text) { + const QStringList parts = text.split(QLatin1Char('.'), Qt::KeepEmptyParts); + if (parts.size() != 2) { + return false; + } + if (!ipv4OctetTokenOk(parts.at(0))) { + return false; + } + const QString &p1 = parts.at(1); + static const QRegularExpression digitThenLetter(QStringLiteral(R"(^\d+[a-zA-Z])")); + if (!digitThenLetter.match(p1).hasMatch()) { + return false; + } + return !ipv4OctetTokenOk(p1); + } + +// "a.b.c.djunk" where first three parts are pure octets and last part has digits then letters (e.g. "123wdqweqweqwe"). + bool looksLikeFourOctetIpv4WithGarbageInLastSegment(const QString &text) { + const QStringList parts = text.split(QLatin1Char('.'), Qt::KeepEmptyParts); + if (parts.size() != 4) { + return false; + } + for (int i = 0; i < 3; ++i) { + if (!ipv4OctetTokenOk(parts.at(i))) { + return false; + } + } + static const QRegularExpression digitThenLetter(QStringLiteral(R"(^\d+[a-zA-Z])")); + return digitThenLetter.match(parts.at(3)).hasMatch(); + } + + bool hostLabelsRejectBrokenDigitLetterMix(const QString &text) { + if (looksLikeTwoSegmentOctetThenDigitLetterGlue(text)) { + return false; + } + if (looksLikeFourOctetIpv4WithGarbageInLastSegment(text)) { + return false; + } + const QStringList parts = text.split(QLatin1Char('.'), Qt::KeepEmptyParts); + for (const QString &part: parts) { + if (labelHasInvalidOctetLikePrefixBeforeLetters(part)) { + return false; + } + } + return true; + } + +} // namespace + +bool mtproxyPublicHostInputAllowed(const QString &text) { + if (text.length() > 253) { + return false; + } + static const QRegularExpression allowed(QStringLiteral(R"(^[a-zA-Z0-9.:\-]*$)")); + if (!allowed.match(text).hasMatch()) { + return false; + } + static const QRegularExpression onlyDigits(QStringLiteral(R"(^\d+$)")); + if (onlyDigits.match(text).hasMatch() && text.length() > 3) { + return false; + } + static const QRegularExpression onlyDigitDot(QStringLiteral(R"(^[0-9.]+$)")); + if (!text.isEmpty() && onlyDigitDot.match(text).hasMatch()) { + static const QRegularExpression ipv4Partial(QStringLiteral(R"(^(\d{1,3}\.){0,3}\d{0,3}$)")); + return ipv4Partial.match(text).hasMatch(); + } + if (text.contains(QLatin1Char(':'))) { + static const QRegularExpression ipv6Chars(QStringLiteral(R"(^[0-9a-fA-F:.]*$)")); + if (!ipv6Chars.match(text).hasMatch()) { + return false; + } + if (text.size() > 45) { + return false; + } + } + if (!hostLabelsRejectBrokenDigitLetterMix(text)) { + return false; + } + return true; +} + +PublicHostInputValidator::PublicHostInputValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State PublicHostInputValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos) + return mtproxyPublicHostInputAllowed(input) ? Acceptable : Invalid; +} diff --git a/client/ui/models/utils/mtproxy_public_host_input.h b/client/ui/models/utils/mtproxy_public_host_input.h new file mode 100644 index 000000000..9f3cffed4 --- /dev/null +++ b/client/ui/models/utils/mtproxy_public_host_input.h @@ -0,0 +1,20 @@ +#ifndef MTPROXY_PUBLIC_HOST_INPUT_H +#define MTPROXY_PUBLIC_HOST_INPUT_H + +#include + +#include + +/// Shared rules for public host field (IPv4 dotted partial, IPv6 hex, FQDN ASCII). +bool mtproxyPublicHostInputAllowed(const QString &text); + +class PublicHostInputValidator : public QValidator { +Q_OBJECT + +public: + explicit PublicHostInputValidator(QObject *parent = nullptr); + + QValidator::State validate(QString &input, int &pos) const override; +}; + +#endif diff --git a/client/ui/qml/Components/SettingsContainersListView.qml b/client/ui/qml/Components/SettingsContainersListView.qml index c5dd130e1..474803243 100644 --- a/client/ui/qml/Components/SettingsContainersListView.qml +++ b/client/ui/qml/Components/SettingsContainersListView.qml @@ -45,6 +45,9 @@ ListViewType { PageController.goToPage(PageEnum.PageProtocolRaw) } else if (isDns) { PageController.goToPage(PageEnum.PageServiceDnsSettings) + } else if (isMtProxy) { + MtProxyConfigModel.updateModel(config) + PageController.goToPage(PageEnum.PageServiceMtProxySettings) } else { InstallController.updateProtocols(ServersUiController.processedIndex, containerIndex) PageController.goToPage(PageEnum.PageSettingsServerProtocol) diff --git a/client/ui/qml/Controls2/ListViewWithRadioButtonType.qml b/client/ui/qml/Controls2/ListViewWithRadioButtonType.qml index abd4da3e2..9454caec5 100644 --- a/client/ui/qml/Controls2/ListViewWithRadioButtonType.qml +++ b/client/ui/qml/Controls2/ListViewWithRadioButtonType.qml @@ -31,6 +31,9 @@ ListViewType { function triggerCurrentItem() { var item = root.itemAtIndex(selectedIndex) + if (!item) { + return + } item.selectable.clicked() } diff --git a/client/ui/qml/Pages2/PageServiceMtProxySettings.qml b/client/ui/qml/Pages2/PageServiceMtProxySettings.qml new file mode 100644 index 000000000..0fed91373 --- /dev/null +++ b/client/ui/qml/Pages2/PageServiceMtProxySettings.qml @@ -0,0 +1,1885 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import SortFilterProxyModel 0.2 + +import PageEnum 1.0 +import ContainerProps 1.0 +import ProtocolEnum 1.0 +import Style 1.0 +import MtProxyConfig 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + + +PageType { + id: root + + Rectangle { + anchors.fill: parent + z: -1 + color: AmneziaStyle.color.onyxBlack + } + + property int containerStatus: 1 + property bool isUpdating: false + property bool isCheckingStatus: false + property bool previousEnabled: true + property int previousContainerStatus: 1 + + property string previousPort: "" + property string previousTag: "" + property string previousPublicHost: "" + property string previousTransportMode: MtProxyConfigModel.transportModeStandard() + property string previousTlsDomain: MtProxyConfigModel.defaultTlsDomain() + property string previousWorkersMode: MtProxyConfigModel.workersModeAuto() + property string previousWorkers: MtProxyConfigModel.defaultWorkers() + property bool previousNatEnabled: false + property string previousNatInternalIp: "" + property string previousNatExternalIp: "" + property string previousSecret: "" + + property string savedTransportMode: "" + property string savedTlsDomain: "" + property string savedPublicHost: "" + + onSavedTransportModeChanged: { + if (savedTransportMode === "faketls") { + root.syncedSecretTabIndex = 2 + } else if (savedTransportMode !== "") { + root.syncedSecretTabIndex = 0 + } + } + + property bool diagLoading: false + property int syncedSecretTabIndex: 0 + property bool pendingEnableAfterRestart: false + property bool pendingUpdateAfterEnable: false + property bool diagPortReachable: false + property bool diagTelegramReachable: false + property int diagClientsConnected: -1 + property string diagLastConfigRefresh: "" + property string diagStatsEndpoint: "" + + readonly property bool mtProxyNetworkBlocked: !NetworkReachabilityController.hasInternetAccess + + readonly property bool navigationBlockedWhileBusy: isUpdating || diagLoading + + // Hex values that exist in last loaded / last successfully saved config — show link panel only for these. + property var mtProxyPersistedAdditionalHex: [] + + function mtProxyRefreshPersistedAdditionalSecrets() { + var list = MtProxyConfigModel.additionalSecretsList() + var a = [] + for (var i = 0; i < list.length; ++i) { + a.push(String(list[i])) + } + root.mtProxyPersistedAdditionalHex = a + } + + function mtProxyIsPersistedAdditionalHex(hex) { + var h = String(hex) + for (var j = 0; j < root.mtProxyPersistedAdditionalHex.length; ++j) { + if (String(root.mtProxyPersistedAdditionalHex[j]) === h) { + return true + } + } + return false + } + + // Rejects garbage like "123123123123"; only dotted IPv4 shape (≤3 digits per octet, ≤4 octets). + readonly property var natIpv4InputFormat: /^(\d{1,3}\.){0,3}\d{0,3}$/ + + // Defer SSH/updateContainer so QML control handlers return before nested event loops run; + // avoids "Object destroyed while one of its QML signal handlers is in progress". + function mtProxyScheduleUpdate(closePage) { + var cp = closePage === undefined ? false : closePage + Qt.callLater(function () { + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.MtProxy, cp) + }) + } + + // Optional IPv4: show invalid while typing only when the string looks complete (four octets), so partial entry is not nagged. + function natIpv4FieldShowInvalidError(text) { + var t = text ? String(text).replace(/^\s+|\s+$/g, '') : "" + if (t === "") + return false + if (MtProxyConfigModel.isValidOptionalIpv4(t)) + return false + var parts = t.split('.') + var j + for (j = 0; j < parts.length; j++) { + if (parts[j].length > 3) + return true + } + if (parts.length > 4) + return true + if (t.indexOf('.') < 0 && t.length > 3) + return true + if (t.endsWith('.')) + return false + if (parts.length < 4) + return false + for (var i = 0; i < parts.length; i++) { + if (parts[i] === "") + return true + } + return true + } + + function statusText() { + if (isCheckingStatus) { + return qsTr("Checking...") + } + if (isUpdating) { + return qsTr("Updating") + } + switch (containerStatus) { + case 0: { + return qsTr("Not deployed") + } + case 1: { + return qsTr("Running") + } + case 2: { + return qsTr("Stopped") + } + case 3: { + return qsTr("Error") + } + default: { + return qsTr("Unknown") + } + } + } + + Component.onCompleted: { + root.savedTransportMode = MtProxyConfigModel.getTransportMode() + root.savedTlsDomain = MtProxyConfigModel.getTlsDomain() + root.savedPublicHost = MtProxyConfigModel.getPublicHost() + + Qt.callLater(function () { + root.mtProxyRefreshPersistedAdditionalSecrets() + }) + + if (!NetworkReachabilityController.hasInternetAccess) { + isCheckingStatus = false + return + } + isCheckingStatus = true + InstallController.refreshContainerStatus(ServersUiController.processedIndex, ServersUiController.processedContainerIndex) + } + + // Block back navigation and Escape (via PageStart.isControlsDisabled) while SSH/update or diagnostics refresh runs. + onNavigationBlockedWhileBusyChanged: { + if (root.visible) { + PageController.disableControls(navigationBlockedWhileBusy) + } + } + + onVisibleChanged: { + if (!visible) { + PageController.disableControls(false) + diagLoading = false + } else { + PageController.disableControls(navigationBlockedWhileBusy) + } + } + + Connections { + target: NetworkReachabilityController + + function onHasInternetAccessChanged() { + if (!root.visible) { + return + } + if (NetworkReachabilityController.hasInternetAccess) { + isCheckingStatus = true + InstallController.refreshContainerStatus(ServersUiController.processedIndex, ServersUiController.processedContainerIndex) + } + } + } + + Connections { + target: InstallController + + function onUpdateContainerFinished(message, closePage) { + if (!root.visible) { + isUpdating = false + isCheckingStatus = false + return + } + isUpdating = false + containerStatus = 1 + root.savedTransportMode = MtProxyConfigModel.getTransportMode() + root.savedTlsDomain = MtProxyConfigModel.getTlsDomain() + root.savedPublicHost = MtProxyConfigModel.getPublicHost() + root.mtProxyRefreshPersistedAdditionalSecrets() + PageController.showNotificationMessage(message) + } + + function onInstallationErrorOccurred() { + if (!root.visible) { + isUpdating = false + isCheckingStatus = false + return + } + isUpdating = false + containerStatus = previousContainerStatus + MtProxyConfigModel.setEnabled(previousEnabled) + MtProxyConfigModel.setPort(previousPort) + MtProxyConfigModel.setTag(previousTag) + MtProxyConfigModel.setPublicHost(previousPublicHost) + MtProxyConfigModel.setTransportMode(previousTransportMode) + MtProxyConfigModel.setTlsDomain(previousTlsDomain) + MtProxyConfigModel.setWorkersMode(previousWorkersMode) + MtProxyConfigModel.setWorkers(previousWorkers) + MtProxyConfigModel.setNatEnabled(previousNatEnabled) + MtProxyConfigModel.setNatInternalIp(previousNatInternalIp) + MtProxyConfigModel.setNatExternalIp(previousNatExternalIp) + if (previousSecret !== "") { + MtProxyConfigModel.setSecret(previousSecret) + } + } + + function onSetContainerEnabledFinished(enabled) { + if (!root.visible) { + isUpdating = false + return + } + if (enabled && pendingUpdateAfterEnable) { + pendingUpdateAfterEnable = false + root.mtProxyScheduleUpdate(false) + return + } + isUpdating = false + containerStatus = enabled ? 1 : 2 + PageController.showNotificationMessage( + enabled ? qsTr("MTProxy started") : qsTr("MTProxy stopped")) + } + + function onContainerStatusRefreshed(status) { + if (!root.visible) { + isCheckingStatus = false + return + } + isCheckingStatus = false + containerStatus = status + + root.savedTransportMode = MtProxyConfigModel.getTransportMode() + root.savedTlsDomain = MtProxyConfigModel.getTlsDomain() + root.savedPublicHost = MtProxyConfigModel.getPublicHost() + if (status === 1) { + MtProxyConfigModel.setEnabled(true) + InstallController.fetchContainerSecret(ServersUiController.processedIndex, ServersUiController.processedContainerIndex) + } else if (status === 2) { + MtProxyConfigModel.setEnabled(false) + } + } + + function onContainerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, statsEndpoint) { + if (!root.visible) { + return + } + diagLoading = false + diagPortReachable = portReachable + diagTelegramReachable = upstreamReachable + diagClientsConnected = clientsConnected + diagLastConfigRefresh = lastConfigRefresh + diagStatsEndpoint = statsEndpoint + } + + function onContainerSecretFetched(secret) { + if (!root.visible) { + return + } + MtProxyConfigModel.validateAndSetSecret(secret) + } + } + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + SettingsController.safeAreaTopMargin + onFocusChanged: { + if (this.activeFocus) connectionListView.positionViewAtBeginning() + } + } + + ColumnLayout { + id: pageHeader + anchors.top: backButton.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 8 + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("MTProxy settings") + } + + LabelWithButtonType { + Layout.fillWidth: true + Layout.leftMargin: 0 + Layout.rightMargin: 16 + text: qsTr("Read more about this settings") + textColor: AmneziaStyle.color.goldenApricot + clickedFunction: function () { + Qt.openUrlExternally("https://core.telegram.org/proxy") + } + } + + TabBar { + id: mainTabBar + Layout.fillWidth: true + Layout.topMargin: 4 + + background: Rectangle { + color: AmneziaStyle.color.transparent + Rectangle { + width: parent.width + height: 1 + anchors.bottom: parent.bottom + color: AmneziaStyle.color.slateGray + } + } + + TabButtonType { + text: qsTr("Connection") + isSelected: mainTabBar.currentIndex === 0 + } + TabButtonType { + text: qsTr("Settings") + isSelected: mainTabBar.currentIndex === 1 + } + } + } + + StackLayout { + id: tabContent + anchors.top: pageHeader.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + currentIndex: mainTabBar.currentIndex + + ListViewType { + id: connectionListView + model: MtProxyConfigModel + + delegate: ColumnLayout { + width: connectionListView.width + spacing: 0 + + function domainToHex(domain) { + var hex = "" + for (var i = 0; i < domain.length; i++) { + var code = domain.charCodeAt(i).toString(16) + hex += (code.length < 2 ? "0" : "") + code + } + return hex + } + + function secretForMode(mode) { + if (mode === "faketls") { + var domain = root.savedTlsDomain !== "" ? root.savedTlsDomain : MtProxyConfigModel.defaultTlsDomain() + return "ee" + secret + domainToHex(domain) + } else if (mode === "padded") { + return "dd" + secret + } + return secret + } + + property int secretTabIndex: root.syncedSecretTabIndex + + function activeSecret() { + if (root.syncedSecretTabIndex === 0) { + return secretForMode("standard") + } + if (root.syncedSecretTabIndex === 1) { + return secretForMode("padded") + } + return secretForMode("faketls") + } + + function effectiveSecret() { + return activeSecret() + } + + function effectiveHost() { + return root.savedPublicHost !== "" ? root.savedPublicHost : ServersModel.getProcessedServerData("hostName") + } + + function tmeLink() { + return "https://t.me/proxy?server=" + effectiveHost() + "&port=" + port + "&secret=" + activeSecret() + } + + function tgLink() { + return "tg://proxy?server=" + effectiveHost() + "&port=" + port + "&secret=" + activeSecret() + } + + CaptionTextType { + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("Use Telegram connection link") + color: AmneziaStyle.color.mutedGray + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + implicitHeight: linkRow.implicitHeight + 16 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + + RowLayout { + id: linkRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 4 + + CaptionTextType { + Layout.fillWidth: true + text: secret !== "" ? tmeLink() : qsTr("Deploy MTProxy first") + color: secret !== "" ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: 13 + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/qr-code.svg" + imageColor: AmneziaStyle.color.paleGray + visible: secret !== "" + onClicked: { + ExportController.generateQrFromString(tmeLink()) + PageController.goToShareConnectionPage( + qsTr("Telegram connection link"), + qsTr("MTProxy connection link"), + "", "", "") + } + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + visible: secret !== "" + onClicked: { + GC.copyToClipBoard(tmeLink()) + PageController.showNotificationMessage(qsTr("Copied")) + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + implicitHeight: tgLinkRow.implicitHeight + 16 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + visible: secret !== "" + + RowLayout { + id: tgLinkRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 4 + + CaptionTextType { + Layout.fillWidth: true + text: tgLink() + color: AmneziaStyle.color.goldenApricot + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: 13 + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/qr-code.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + ExportController.generateQrFromString(tgLink()) + PageController.goToShareConnectionPage( + qsTr("Telegram connection link"), + qsTr("MTProxy connection link"), + "", "", "") + } + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + GC.copyToClipBoard(tgLink()) + PageController.showNotificationMessage(qsTr("Copied")) + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + spacing: 4 + + CaptionTextType { + text: qsTr("Or enter the proxy details manually.") + color: AmneziaStyle.color.mutedGray + } + + CaptionTextType { + Layout.fillWidth: true + text: qsTr("How to do it") + color: AmneziaStyle.color.goldenApricot + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally("https://core.telegram.org/proxy") + } + } + + Item { + Layout.fillWidth: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 32 + implicitHeight: manualCol.implicitHeight + 8 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + + ColumnLayout { + id: manualCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 8 + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 8 + Layout.bottomMargin: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + CaptionTextType { + text: qsTr("Host") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + CaptionTextType { + Layout.fillWidth: true + text: effectiveHost() + color: AmneziaStyle.color.paleGray + elide: Text.ElideRight + } + } + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { GC.copyToClipBoard(effectiveHost()) + PageController.showNotificationMessage(qsTr("Copied")) } + } + } + + DividerType { + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 8 + Layout.topMargin: 8 + Layout.bottomMargin: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + CaptionTextType { + text: qsTr("Port") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + CaptionTextType { + Layout.fillWidth: true + text: port + color: AmneziaStyle.color.paleGray + } + } + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { GC.copyToClipBoard(port) + PageController.showNotificationMessage(qsTr("Copied")) } + } + } + + DividerType { + Layout.fillWidth: true + } + + ButtonGroup { + id: secretTabGroup + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 8 + Layout.topMargin: 4 + Layout.bottomMargin: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + CaptionTextType { + text: qsTr("Secret") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + CaptionTextType { + Layout.fillWidth: true + text: activeSecret() + color: AmneziaStyle.color.paleGray + wrapMode: Text.WrapAnywhere + font.pixelSize: 13 + } + } + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { GC.copyToClipBoard(activeSecret()) + PageController.showNotificationMessage(qsTr("Copied")) } + } + } + } + } + + LabelWithButtonType { + id: removeButton + Layout.fillWidth: true + Layout.bottomMargin: 24 + Layout.leftMargin: 0 + Layout.rightMargin: 16 + visible: ServersModel.isProcessedServerHasWriteAccess() + text: qsTr("Delete MTProxy") + textColor: AmneziaStyle.color.vibrantRed + clickedFunction: function () { + var headerText = qsTr("Remove %1 from server?").arg(ContainersModel.getProcessedContainerName()) + var descriptionText = qsTr("The proxy will be stopped and all users will lose access.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + PageController.goToPage(PageEnum.PageDeinstalling) + InstallController.removeContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex) + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, function () { + }) + } + MouseArea { + anchors.fill: removeButton + cursorShape: Qt.PointingHandCursor + enabled: false + } + } + } + } + + ListViewType { + id: settingsListView + model: MtProxyConfigModel + reuseItems: false + + delegate: ColumnLayout { + id: settingsRoot + width: settingsListView.width + spacing: 0 + + function mtProxyDomainToHex(domain) { + var hex = "" + for (var i = 0; i < domain.length; i++) { + var code = domain.charCodeAt(i).toString(16) + hex += (code.length < 2 ? "0" : "") + code + } + return hex + } + + function mtProxySecretForBaseHex(baseHex, mode) { + if (mode === "faketls") { + var domain = root.savedTlsDomain !== "" ? root.savedTlsDomain : MtProxyConfigModel.defaultTlsDomain() + return "ee" + baseHex + mtProxyDomainToHex(domain) + } else if (mode === "padded") { + return "dd" + baseHex + } + return baseHex + } + + function mtProxyActiveSecretForBaseHex(baseHex) { + if (root.syncedSecretTabIndex === 0) { + return mtProxySecretForBaseHex(baseHex, "standard") + } + if (root.syncedSecretTabIndex === 1) { + return mtProxySecretForBaseHex(baseHex, "padded") + } + return mtProxySecretForBaseHex(baseHex, "faketls") + } + + function mtProxyEffectiveHostForLinks() { + return root.savedPublicHost !== "" ? root.savedPublicHost : ServersModel.getProcessedServerData("hostName") + } + + function mtProxyTmeLinkForAdditional(baseHex) { + return "https://t.me/proxy?server=" + mtProxyEffectiveHostForLinks() + "&port=" + port + "&secret=" + mtProxyActiveSecretForBaseHex(baseHex) + } + + function mtProxyTgLinkForAdditional(baseHex) { + return "tg://proxy?server=" + mtProxyEffectiveHostForLinks() + "&port=" + port + "&secret=" + mtProxyActiveSecretForBaseHex(baseHex) + } + + SwitcherType { + id: enableMtProxySwitch + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + text: qsTr("Enable MTProxy") + checked: isEnabled + enabled: !isCheckingStatus && containerStatus !== 0 && containerStatus !== 3 && !isUpdating + && !root.mtProxyNetworkBlocked + onToggled: function () { + if (checked !== isEnabled) { + previousEnabled = isEnabled + previousContainerStatus = containerStatus + root.previousSecret = secret + isEnabled = checked + isUpdating = true + if (checked) { + root.pendingUpdateAfterEnable = true + InstallController.setContainerEnabled(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, true) + } else { + InstallController.setContainerEnabled(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, false) + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 * 2 + spacing: 4 + + CaptionTextType { + text: qsTr("Base secret") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + CaptionTextType { + Layout.fillWidth: true + text: secret !== "" ? secret : qsTr("Not generated") + color: secret !== "" ? AmneziaStyle.color.paleGray : AmneziaStyle.color.mutedGray + elide: Text.ElideMiddle + font.pixelSize: 14 + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/refresh-cw.svg" + imageColor: AmneziaStyle.color.paleGray + visible: ServersModel.isProcessedServerHasWriteAccess() + onClicked: { + var secretSnapshot = secret + showQuestionDrawer( + qsTr("Generate new secret?"), + qsTr("All existing connection links will stop working. Users will need new links."), + qsTr("Generate"), + qsTr("Cancel"), + function () { + root.previousSecret = secretSnapshot + if (containerStatus === 1) { + isUpdating = true + MtProxyConfigModel.generateSecret() + root.mtProxyScheduleUpdate(false) + } else { + MtProxyConfigModel.generateSecret() + PageController.showNotificationMessage(qsTr("New secret saved. It will be applied when MTProxy is started.")) + } + }, + function () { + } + ) + } + } + } + } + + TextFieldWithHeaderType { + id: publicHostTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 4 + headerText: qsTr("Public host / IP") + textField.placeholderText: ServersModel.getProcessedServerData("hostName") + textField.text: publicHost + textField.maximumLength: 253 + textField.validator: PublicHostInputValidator { + } + textField.onTextChanged: { + var t = publicHostTextField.textField.text + if (MtProxyConfigModel.isPublicHostTypingIncomplete(t)) { + publicHostTextField.errorText = "" + } else if (!MtProxyConfigModel.isValidPublicHost(t)) { + publicHostTextField.errorText = qsTr("Enter a valid IP address or domain name") + } else { + publicHostTextField.errorText = "" + } + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!MtProxyConfigModel.isValidPublicHost(textField.text)) { + publicHostTextField.errorText = qsTr("Enter a valid IP address or domain name") + return + } + publicHostTextField.errorText = "" + if (textField.text !== publicHost) { + publicHost = textField.text + MtProxyConfigModel.setPublicHost(publicHost) + } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 4 + visible: publicHostTextField.textField.text === "" + text: qsTr("Leave empty to use server IP automatically") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + visible: publicHostTextField.textField.text !== "" && + publicHostTextField.textField.text !== ServersModel.getProcessedServerData("hostName") + text: qsTr("⚠ This overrides the server IP in connection links. Make sure this host/domain points to your server.") + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + TextFieldWithHeaderType { + id: portTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + headerText: qsTr("Server port") + textField.placeholderText: MtProxyConfigModel.defaultPort() + textField.maximumLength: 5 + textField.validator: IntValidator { + bottom: 1 + top: 65535 + } + Component.onCompleted: { + var savedPort = port + textField.text = (savedPort === MtProxyConfigModel.defaultPort()) ? "" : savedPort + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 12 + visible: transportMode === "faketls" && portTextField.textField.text !== "443" && portTextField.textField.text !== "" + text: qsTr("FakeTLS may not work on ports other than 443") + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("The promoted channel is set in @MTProxyBot. Paste the proxy tag here: exactly 32 hexadecimal characters (0-9, A-F), as in the bot message — or leave empty.") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + TextFieldWithHeaderType { + id: tagTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + headerText: qsTr("MTProxy bot tag (optional)") + textField.placeholderText: qsTr("32 hex chars from @MTProxyBot (e.g. 3b7b2fa9…)") + textField.text: tag + textField.maximumLength: MtProxyConfigModel.mtProxyBotTagHexLength() + textField.onTextChanged: { + var cur = tagTextField.textField.text + var clean = MtProxyConfigModel.sanitizeMtProxyTagFieldText(cur) + if (clean !== cur) { + textField.text = clean + textField.cursorPosition = clean.length + return + } + var tt = tagTextField.textField.text + if (tt === "") { + tagTextField.errorText = "" + return + } + if (MtProxyConfigModel.isMtProxyTagTypingIncomplete(tt)) { + tagTextField.errorText = "" + return + } + if (!MtProxyConfigModel.isValidMtProxyTag(tt)) { + tagTextField.errorText = qsTr("Proxy tag must be exactly 32 hexadecimal characters (0-9, A-F).") + return + } + tagTextField.errorText = "" + } + textField.onEditingFinished: { + var raw = textField.text.replace(/^\s+|\s+$/g, '') + var normalized = MtProxyConfigModel.sanitizeMtProxyTagFieldText(raw) + textField.text = normalized + if (!MtProxyConfigModel.isValidMtProxyTag(normalized)) { + tagTextField.errorText = qsTr("Proxy tag must be exactly 32 hexadecimal characters (0-9, A-F). Leave empty if unused.") + return + } + tagTextField.errorText = "" + if (normalized !== tag) { + tag = normalized + MtProxyConfigModel.setTag(tag) + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + spacing: 4 + + CaptionTextType { + text: qsTr("Get a tag from") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + CaptionTextType { + text: "@MTProxyBot" + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 12 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally("https://t.me/MTProxyBot") + } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 * 2 + text: qsTr("Transport mode") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + } + + DropDownType { + id: transportModeDropDown + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + + drawerParent: root + drawerHeight: 0.4 + headerText: qsTr("Transport mode") + text: transportMode === "faketls" ? qsTr("FakeTLS") : qsTr("Standard MTProto") + + listView: Component { + ListViewType { + model: [qsTr("Standard MTProto"), qsTr("FakeTLS")] + delegate: LabelWithButtonType { + Layout.fillWidth: true + text: modelData + rightImageSource: { + var isCurrent = (index === 0 && transportMode === "standard") || + (index === 1 && transportMode === "faketls") + return isCurrent ? "qrc:/images/controls/check.svg" : "" + } + rightImageColor: AmneziaStyle.color.goldenApricot + clickedFunction: function () { + transportMode = (index === 0) ? "standard" : "faketls" + MtProxyConfigModel.setTransportMode(transportMode) + transportModeDropDown.closeTriggered() + } + } + } + } + } + + TextFieldWithHeaderType { + id: tlsDomainTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + visible: transportMode === "faketls" + headerText: qsTr("FakeTLS domain") + textField.placeholderText: root.previousTlsDomain + Component.onCompleted: { + var savedDomain = tlsDomain + textField.text = (savedDomain === MtProxyConfigModel.defaultTlsDomain() || savedDomain === "") ? "" : savedDomain + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + var domainValue = textField.text === "" ? MtProxyConfigModel.defaultTlsDomain() : textField.text + if (!MtProxyConfigModel.isValidFakeTlsDomain(domainValue)) { + tlsDomainTextField.errorText = qsTr("Enter a valid domain name") + return + } + tlsDomainTextField.errorText = "" + if (domainValue !== tlsDomain) { + tlsDomain = domainValue + MtProxyConfigModel.setTlsDomain(tlsDomain) + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + spacing: 4 + visible: transportMode === "faketls" + + CaptionTextType { + Layout.fillWidth: true + text: qsTr("The domain is encoded into the FakeTLS client secret (ee + base_secret + hex(domain)). It must support HTTPS / TLS 1.3.") + color: AmneziaStyle.color.mutedGray + wrapMode: Text.WordWrap + font.pixelSize: 12 + } + CaptionTextType { + Layout.fillWidth: true + text: qsTr("\u26a0 Changing the domain will invalidate all previously issued FakeTLS connection links.") + color: AmneziaStyle.color.goldenApricot + wrapMode: Text.WordWrap + font.pixelSize: 12 + } + } + + LabelWithButtonType { + id: advancedHeader + Layout.fillWidth: true + Layout.leftMargin: 0 + Layout.rightMargin: 16 + property bool expanded: false + text: qsTr("Advanced") + rightImageSource: expanded + ? "qrc:/images/controls/chevron-up.svg" + : "qrc:/images/controls/chevron-down.svg" + rightImageColor: AmneziaStyle.color.mutedGray + clickedFunction: function () { + expanded = !expanded + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + visible: advancedHeader.expanded + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 4 + text: qsTr("Additional secrets") + color: AmneziaStyle.color.mutedGray + } + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("Add extra secrets to allow gradual migration without disconnecting existing users.") + color: AmneziaStyle.color.charcoalGray + wrapMode: Text.WordWrap + font.pixelSize: 12 + } + + Repeater { + model: additionalSecrets + delegate: ColumnLayout { + id: addSecretDelegate + property bool linksExpanded: false + readonly property bool linksPanelAllowed: root.mtProxyIsPersistedAdditionalHex(modelData) + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + spacing: 0 + + onLinksPanelAllowedChanged: { + if (!linksPanelAllowed) { + linksExpanded = false + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: collapsedBar.implicitHeight + 16 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + + RowLayout { + id: collapsedBar + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + + Item { + Layout.fillWidth: true + implicitHeight: Math.max(hexCaption.implicitHeight, 24) + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + CaptionTextType { + id: hexCaption + Layout.fillWidth: true + text: modelData + color: AmneziaStyle.color.paleGray + elide: Text.ElideMiddle + font.pixelSize: 13 + } + + Image { + width: 24 + height: 24 + visible: addSecretDelegate.linksPanelAllowed + source: "qrc:/images/controls/chevron-down.svg" + sourceSize.width: 24 + sourceSize.height: 24 + rotation: addSecretDelegate.linksExpanded ? 180 : 0 + Behavior on rotation { + NumberAnimation { + duration: 150 + } + } + } + } + + MouseArea { + anchors.fill: parent + visible: addSecretDelegate.linksPanelAllowed + enabled: addSecretDelegate.linksPanelAllowed + cursorShape: Qt.PointingHandCursor + onClicked: addSecretDelegate.linksExpanded = !addSecretDelegate.linksExpanded + } + } + + ImageButtonType { + implicitWidth: 32 + implicitHeight: 32 + hoverEnabled: true + visible: ServersModel.isProcessedServerHasWriteAccess() + image: "qrc:/images/controls/trash.svg" + imageColor: AmneziaStyle.color.vibrantRed + onClicked: { + MtProxyConfigModel.removeAdditionalSecret(index) + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + visible: addSecretDelegate.linksPanelAllowed && addSecretDelegate.linksExpanded + + CaptionTextType { + Layout.fillWidth: true + text: qsTr("Use Telegram connection link") + color: AmneziaStyle.color.mutedGray + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: expTmeRow.implicitHeight + 16 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + + RowLayout { + id: expTmeRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 4 + + CaptionTextType { + Layout.fillWidth: true + text: settingsRoot.mtProxyTmeLinkForAdditional(modelData) + color: AmneziaStyle.color.goldenApricot + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: 13 + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/qr-code.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + ExportController.generateQrFromString(settingsRoot.mtProxyTmeLinkForAdditional(modelData)) + PageController.goToShareConnectionPage( + qsTr("Telegram connection link"), + qsTr("MTProxy connection link"), + "", "", "") + } + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + GC.copyToClipBoard(settingsRoot.mtProxyTmeLinkForAdditional(modelData)) + PageController.showNotificationMessage(qsTr("Copied")) + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: expTgRow.implicitHeight + 16 + color: AmneziaStyle.color.onyxBlack + radius: 8 + border.color: AmneziaStyle.color.slateGray + border.width: 1 + + RowLayout { + id: expTgRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 4 + + CaptionTextType { + Layout.fillWidth: true + text: settingsRoot.mtProxyTgLinkForAdditional(modelData) + color: AmneziaStyle.color.goldenApricot + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: 13 + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/qr-code.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + ExportController.generateQrFromString(settingsRoot.mtProxyTgLinkForAdditional(modelData)) + PageController.goToShareConnectionPage( + qsTr("Telegram connection link"), + qsTr("MTProxy connection link"), + "", "", "") + } + } + + ImageButtonType { + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: { + GC.copyToClipBoard(settingsRoot.mtProxyTgLinkForAdditional(modelData)) + PageController.showNotificationMessage(qsTr("Copied")) + } + } + } + } + } + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 8 + + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + text: qsTr("Add additional secret") + clickedFunc: function () { + MtProxyConfigModel.addAdditionalSecret() + } + } + + DividerType { + Layout.fillWidth: true + Layout.bottomMargin: 8 + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.bottomMargin: 4 + text: qsTr("Worker mode") + } + + ButtonGroup { + id: workerModeGroup + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 4 + spacing: 0 + visible: transportMode !== "faketls" + + HorizontalRadioButton { + Layout.fillWidth: true + text: qsTr("Auto") + ButtonGroup.group: workerModeGroup + checked: workersMode === "auto" + onClicked: { workersMode = "auto"; MtProxyConfigModel.setWorkersMode("auto") } + } + HorizontalRadioButton { + Layout.fillWidth: true + text: qsTr("Manual") + ButtonGroup.group: workerModeGroup + checked: workersMode === "manual" + onClicked: { workersMode = "manual"; MtProxyConfigModel.setWorkersMode("manual") } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + visible: transportMode === "faketls" + text: qsTr("Workers are set to 0 automatically for FakeTLS mode.") + color: AmneziaStyle.color.mutedGray + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + TextFieldWithHeaderType { + id: workersTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + visible: workersMode === "manual" && transportMode !== "faketls" + headerText: qsTr("Workers count") + textField.placeholderText: "2" + textField.text: workers + textField.maximumLength: 3 + textField.validator: IntValidator { + bottom: 1 + top: MtProxyConfigModel.maxWorkers() + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== workers) { + workers = textField.text + MtProxyConfigModel.setWorkers(workers) + } + } + } + + DividerType { + Layout.fillWidth: true + Layout.bottomMargin: 8 + } + + SwitcherType { + Layout.fillWidth: true + Layout.rightMargin: 16 + Layout.leftMargin: 16 + Layout.bottomMargin: 4 + text: qsTr("Server is behind NAT / Docker bridge") + descriptionText: qsTr("Enable if your server is not directly accessible from the internet, e.g. Docker or private network") + checked: natEnabled + onToggled: function () { + if (checked !== natEnabled) { + natEnabled = checked + MtProxyConfigModel.setNatEnabled(natEnabled) + } + } + } + + TextFieldWithHeaderType { + id: natInternalIpTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + visible: natEnabled + headerText: qsTr("Internal IP") + textField.placeholderText: "172.17.0.2" + textField.text: natInternalIp + textField.maximumLength: 15 + textField.validator: RegularExpressionValidator { + regularExpression: root.natIpv4InputFormat + } + textField.onTextChanged: { + if (root.natIpv4FieldShowInvalidError(textField.text)) { + natInternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + } else { + natInternalIpTextField.errorText = "" + } + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!MtProxyConfigModel.isValidOptionalIpv4(textField.text)) { + natInternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + return + } + natInternalIpTextField.errorText = "" + if (textField.text !== natInternalIp) { + natInternalIp = textField.text + MtProxyConfigModel.setNatInternalIp(natInternalIp) + } + } + } + + TextFieldWithHeaderType { + id: natExternalIpTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + visible: natEnabled + headerText: qsTr("External IP") + textField.placeholderText: "1.2.3.4" + textField.text: natExternalIp + textField.maximumLength: 15 + textField.validator: RegularExpressionValidator { + regularExpression: root.natIpv4InputFormat + } + textField.onTextChanged: { + if (root.natIpv4FieldShowInvalidError(textField.text)) { + natExternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + } else { + natExternalIpTextField.errorText = "" + } + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!MtProxyConfigModel.isValidOptionalIpv4(textField.text)) { + natExternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + return + } + natExternalIpTextField.errorText = "" + if (textField.text !== natExternalIp) { + natExternalIp = textField.text + MtProxyConfigModel.setNatExternalIp(natExternalIp) + } + } + } + } + + DividerType { + Layout.fillWidth: true + Layout.topMargin: 8 + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + spacing: 8 + visible: containerStatus === 1 + + RowLayout { + Layout.fillWidth: true + + Header2Type { + Layout.fillWidth: true + headerText: qsTr("Diagnostics") + } + + ImageButtonType { + implicitWidth: 32 + implicitHeight: 32 + image: "qrc:/images/controls/refresh-cw.svg" + imageColor: diagLoading ? AmneziaStyle.color.mutedGray : AmneziaStyle.color.paleGray + hoverEnabled: !diagLoading + enabled: !diagLoading + onClicked: { + diagLoading = true + InstallController.refreshContainerDiagnostics(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, parseInt(port)) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: diagClientsConnected >= 0 ? (diagPortReachable ? AmneziaStyle.color.paleGray : AmneziaStyle.color.vibrantRed) : AmneziaStyle.color.mutedGray + } + CaptionTextType { + Layout.fillWidth: true + text: qsTr("Public port reachable") + color: AmneziaStyle.color.paleGray + } + CaptionTextType { + text: diagClientsConnected < 0 ? qsTr("—") : (diagPortReachable ? qsTr("Yes") : qsTr("No")) + color: diagClientsConnected >= 0 ? (diagPortReachable ? AmneziaStyle.color.paleGray : AmneziaStyle.color.vibrantRed) : AmneziaStyle.color.mutedGray + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: diagClientsConnected >= 0 ? (diagTelegramReachable ? AmneziaStyle.color.paleGray : AmneziaStyle.color.vibrantRed) : AmneziaStyle.color.mutedGray + } + CaptionTextType { + Layout.fillWidth: true + text: qsTr("Telegram upstream reachable") + color: AmneziaStyle.color.paleGray + } + CaptionTextType { + text: diagClientsConnected < 0 ? qsTr("—") : (diagTelegramReachable ? qsTr("Yes") : qsTr("No")) + color: diagClientsConnected >= 0 ? (diagTelegramReachable ? AmneziaStyle.color.paleGray : AmneziaStyle.color.vibrantRed) : AmneziaStyle.color.mutedGray + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: diagClientsConnected >= 0 ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray + } + CaptionTextType { + Layout.fillWidth: true + text: qsTr("Clients connected") + color: AmneziaStyle.color.paleGray + } + CaptionTextType { + text: diagClientsConnected < 0 ? qsTr("—") : diagClientsConnected.toString() + color: AmneziaStyle.color.paleGray + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: AmneziaStyle.color.mutedGray + } + CaptionTextType { + Layout.fillWidth: true + text: qsTr("Last config refresh") + color: AmneziaStyle.color.paleGray + } + CaptionTextType { + text: diagLastConfigRefresh !== "" ? diagLastConfigRefresh : qsTr("—") + color: AmneziaStyle.color.mutedGray + } + } + + LabelWithButtonType { + Layout.fillWidth: true + Layout.leftMargin: -16 + visible: diagStatsEndpoint !== "" + text: qsTr("Stats endpoint") + descriptionText: diagStatsEndpoint + descriptionOnTop: true + rightImageSource: "qrc:/images/controls/copy.svg" + rightImageColor: AmneziaStyle.color.paleGray + clickedFunction: function () { + GC.copyToClipBoard(diagStatsEndpoint) + PageController.showNotificationMessage(qsTr("Copied")) + } + } + + CaptionTextType { + Layout.fillWidth: true + text: diagLoading ? qsTr("Refreshing…") : qsTr("Tap ↻ to refresh diagnostics") + color: AmneziaStyle.color.mutedGray + visible: diagClientsConnected < 0 + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 * 2 + Layout.bottomMargin: 24 + text: qsTr("If you change the settings, the proxy connection link will change. The old link will stop working.") + color: AmneziaStyle.color.mutedGray + wrapMode: Text.WordWrap + font.pixelSize: 12 + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.bottomMargin: 32 + Layout.rightMargin: 16 + Layout.leftMargin: 16 + visible: ServersModel.isProcessedServerHasWriteAccess() + enabled: !root.mtProxyNetworkBlocked + text: qsTr("Save") + clickedFunc: function () { + if (root.mtProxyNetworkBlocked) { + PageController.showErrorMessage(qsTr("No internet connection. Connect to the internet to change MTProxy settings.")) + return + } + publicHostTextField.errorText = "" + tagTextField.errorText = "" + tlsDomainTextField.errorText = "" + natInternalIpTextField.errorText = "" + natExternalIpTextField.errorText = "" + portTextField.errorText = "" + + var portValue = portTextField.textField.text === "" + ? MtProxyConfigModel.defaultPort() + : portTextField.textField.text + + var errorLines = [] + var bullet = "- " + if (!portTextField.textField.acceptableInput && portTextField.textField.text !== "") { + var portErr = qsTr("The port must be in the range of 1 to 65535") + portTextField.errorText = portErr + errorLines.push(bullet + portErr) + } + if (!MtProxyConfigModel.isValidPublicHost(publicHostTextField.textField.text)) { + var hostErr = qsTr("Enter a valid IP address or domain name") + publicHostTextField.errorText = hostErr + errorLines.push(bullet + hostErr) + } + var tagNormalized = MtProxyConfigModel.sanitizeMtProxyTagFieldText(tagTextField.textField.text) + tagTextField.textField.text = tagNormalized + if (!MtProxyConfigModel.isValidMtProxyTag(tagNormalized)) { + var tagErr = qsTr("Proxy tag must be exactly 32 hexadecimal characters (0-9, A-F), or leave empty.") + tagTextField.errorText = tagErr + errorLines.push(bullet + tagErr) + } + var domainValueForSave = tlsDomainTextField.textField.text === "" + ? MtProxyConfigModel.defaultTlsDomain() + : tlsDomainTextField.textField.text + if (!MtProxyConfigModel.isValidFakeTlsDomain(domainValueForSave)) { + var tlsErr = qsTr("Enter a valid domain name") + tlsDomainTextField.errorText = tlsErr + errorLines.push(bullet + tlsErr) + } + var natIpErr = qsTr("Enter a valid IPv4 address") + if (!MtProxyConfigModel.isValidOptionalIpv4(natInternalIpTextField.textField.text)) { + natInternalIpTextField.errorText = natIpErr + errorLines.push(bullet + qsTr("NAT internal IP: enter a valid IPv4 address")) + } + if (!MtProxyConfigModel.isValidOptionalIpv4(natExternalIpTextField.textField.text)) { + natExternalIpTextField.errorText = natIpErr + errorLines.push(bullet + qsTr("NAT external IP: enter a valid IPv4 address")) + } + if (errorLines.length > 0) { + PageController.showErrorMessage(errorLines.join("\n")) + return + } + MtProxyConfigModel.setPort(portValue) + MtProxyConfigModel.setTag(tagNormalized) + MtProxyConfigModel.setPublicHost(publicHostTextField.textField.text) + MtProxyConfigModel.setTransportMode(transportMode) + var domainValue = tlsDomainTextField.textField.text === "" + ? MtProxyConfigModel.defaultTlsDomain() + : tlsDomainTextField.textField.text + MtProxyConfigModel.setTlsDomain(domainValue) + + if (transportMode === "faketls") { + workers = "0" + MtProxyConfigModel.setWorkers("0") + } else { + MtProxyConfigModel.setWorkersMode(workersMode) + MtProxyConfigModel.setWorkers(workers) + } + MtProxyConfigModel.setNatEnabled(natEnabled) + MtProxyConfigModel.setNatInternalIp(natInternalIpTextField.textField.text) + MtProxyConfigModel.setNatExternalIp(natExternalIpTextField.textField.text) + + previousPort = port + previousTag = tag + previousPublicHost = publicHost + previousTransportMode = transportMode + previousTlsDomain = tlsDomain + previousWorkersMode = workersMode + previousWorkers = workers + previousNatEnabled = natEnabled + previousNatInternalIp = natInternalIp + previousNatExternalIp = natExternalIp + root.previousSecret = secret + isUpdating = true + root.mtProxyScheduleUpdate(false) + } + } + } + } + } + + Rectangle { + anchors.fill: parent + visible: isCheckingStatus || isUpdating || root.mtProxyNetworkBlocked + color: AmneziaStyle.color.midnightBlack + opacity: 0.6 + z: 1 + MouseArea { + anchors.fill: parent + } + BusyIndicator { + anchors.centerIn: parent + visible: isCheckingStatus || isUpdating + running: isCheckingStatus || isUpdating + width: 48 + height: 48 + } + CaptionTextType { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 24 + anchors.rightMargin: 24 + visible: root.mtProxyNetworkBlocked && !isCheckingStatus && !isUpdating + horizontalAlignment: Text.AlignHCenter + text: qsTr("No internet connection. Connect to the internet to change MTProxy settings.") + color: AmneziaStyle.color.paleGray + wrapMode: Text.WordWrap + font.pixelSize: 14 + } + } +} diff --git a/client/ui/qml/Pages2/PageStart.qml b/client/ui/qml/Pages2/PageStart.qml index d39baa9f8..86352e82a 100644 --- a/client/ui/qml/Pages2/PageStart.qml +++ b/client/ui/qml/Pages2/PageStart.qml @@ -132,9 +132,11 @@ PageType { onInstallationErrorOccurred(message) } - function onUpdateContainerFinished(message) { + function onUpdateContainerFinished(message, closePage) { PageController.showNotificationMessage(message) - PageController.closePage() + if (closePage) { + PageController.closePage() + } } function onCachedProfileCleared(message) { diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index f2a462630..dd4a5041b 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -78,6 +78,7 @@ Pages2/PageProtocolWireGuardSettings.qml Pages2/PageProtocolXraySettings.qml Pages2/PageServiceDnsSettings.qml + Pages2/PageServiceMtProxySettings.qml Pages2/PageServiceSftpSettings.qml Pages2/PageServiceSocksProxySettings.qml Pages2/PageServiceTorWebsiteSettings.qml From 9f3359e1e8310bdac583bee5980445e6f139fc29 Mon Sep 17 00:00:00 2001 From: dranik Date: Mon, 4 May 2026 19:27:13 +0300 Subject: [PATCH 2/3] add path files --- client/server_scripts/serverScripts.qrc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/server_scripts/serverScripts.qrc b/client/server_scripts/serverScripts.qrc index 5ed57b01e..35a2be2a3 100644 --- a/client/server_scripts/serverScripts.qrc +++ b/client/server_scripts/serverScripts.qrc @@ -24,6 +24,10 @@ ipsec/run_container.sh ipsec/start.sh ipsec/strongswan.profile + mtproxy/configure_container.sh + mtproxy/Dockerfile + mtproxy/run_container.sh + mtproxy/start.sh openvpn/configure_container.sh openvpn/Dockerfile openvpn/run_container.sh @@ -55,4 +59,3 @@ xray/template.json - From 05ce813c2363c8cb2167296334c1c806b503e71c Mon Sep 17 00:00:00 2001 From: vkamn Date: Fri, 15 May 2026 21:43:47 +0800 Subject: [PATCH 3/3] refactor: move logic from ui to core --- .../selfhosted/installController.cpp | 110 +++++++++++++++ .../selfhosted/installController.h | 11 ++ client/core/controllers/serversController.cpp | 14 -- client/core/controllers/serversController.h | 1 - client/core/installers/mtProxyInstaller.cpp | 48 +++++++ client/core/installers/mtProxyInstaller.h | 14 ++ .../selfhosted/installUiController.cpp | 133 +++--------------- 7 files changed, 203 insertions(+), 128 deletions(-) diff --git a/client/core/controllers/selfhosted/installController.cpp b/client/core/controllers/selfhosted/installController.cpp index 393c7e567..1f163043c 100644 --- a/client/core/controllers/selfhosted/installController.cpp +++ b/client/core/controllers/selfhosted/installController.cpp @@ -1345,3 +1345,113 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia return ErrorCode::NoError; } + +ErrorCode InstallController::setDockerContainerEnabledState(const QString &serverId, DockerContainer container, bool enabled) +{ + auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId); + if (!adminConfig.has_value()) { + return ErrorCode::InternalError; + } + ServerCredentials credentials = adminConfig->credentials(); + if (!credentials.isValid()) { + return ErrorCode::InternalError; + } + const QString containerName = ContainerUtils::containerToString(container); + SshSession sshSession(this); + const QString script = enabled ? QStringLiteral("sudo docker start %1").arg(containerName) + : QStringLiteral("sudo docker stop %1").arg(containerName); + const ErrorCode runError = sshSession.runScript(credentials, script); + if (runError != ErrorCode::NoError) { + return runError; + } + ContainerConfig currentConfig = adminConfig->containerConfig(container); + if (auto *mtConfig = currentConfig.getMtProxyProtocolConfig()) { + mtConfig->isEnabled = enabled; + adminConfig->updateContainerConfig(container, currentConfig); + m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin); + } + return ErrorCode::NoError; +} + +ErrorCode InstallController::queryDockerContainerStatus(const QString &serverId, DockerContainer container, int &statusOut) +{ + statusOut = 3; + auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId); + if (!adminConfig.has_value()) { + return ErrorCode::InternalError; + } + ServerCredentials credentials = adminConfig->credentials(); + if (!credentials.isValid()) { + return ErrorCode::InternalError; + } + const QString containerName = ContainerUtils::containerToString(container); + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + SshSession sshSession(this); + const QString script = QStringLiteral( + "sudo docker inspect --format '{{.State.Status}}' %1 2>/dev/null || echo 'not_found'") + .arg(containerName); + const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + return errorCode; + } + const QString status = stdOut.trimmed(); + if (status == QLatin1String("running")) { + statusOut = 1; + } else if (status == QLatin1String("not_found") || status.isEmpty()) { + statusOut = 0; + } else if (status == QLatin1String("exited") || status == QLatin1String("created") + || status == QLatin1String("paused")) { + statusOut = 2; + } else { + statusOut = 3; + } + return ErrorCode::NoError; +} + +ErrorCode InstallController::queryMtProxyDiagnostics(const QString &serverId, DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out) +{ + out = {}; + auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId); + if (!adminConfig.has_value()) { + return ErrorCode::InternalError; + } + ServerCredentials credentials = adminConfig->credentials(); + if (!credentials.isValid()) { + return ErrorCode::InternalError; + } + SshSession sshSession(this); + return MtProxyInstaller::queryDiagnostics(sshSession, credentials, container, listenPort, out); +} + +QString InstallController::fetchDockerContainerSecret(const QString &serverId, DockerContainer container) +{ + auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId); + if (!adminConfig.has_value()) { + return {}; + } + ServerCredentials credentials = adminConfig->credentials(); + if (!credentials.isValid()) { + return {}; + } + const QString containerName = ContainerUtils::containerToString(container); + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + SshSession sshSession(this); + const QString path = QStringLiteral("/data/secret"); + const QString cmd = QStringLiteral("sudo docker exec %1 cat %2").arg(containerName, path); + const ErrorCode errorCode = sshSession.runScript(credentials, cmd, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + return {}; + } + const QString secret = stdOut.trimmed(); + static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$")); + return hex32.match(secret).hasMatch() ? secret : QString(); +} diff --git a/client/core/controllers/selfhosted/installController.h b/client/core/controllers/selfhosted/installController.h index acb61ca41..25c041273 100644 --- a/client/core/controllers/selfhosted/installController.h +++ b/client/core/controllers/selfhosted/installController.h @@ -16,6 +16,7 @@ #include "core/models/containerConfig.h" #include "core/repositories/secureServersRepository.h" #include "core/repositories/secureAppSettingsRepository.h" +#include "core/installers/mtProxyInstaller.h" class SshSession; class InstallerBase; @@ -39,6 +40,16 @@ public: ErrorCode removeAllContainers(const QString &serverId); ErrorCode removeContainer(const QString &serverId, DockerContainer container); + ErrorCode setDockerContainerEnabledState(const QString &serverId, DockerContainer container, bool enabled); + + /// statusOut: 0 = not deployed, 1 = running, 2 = stopped, 3 = error + ErrorCode queryDockerContainerStatus(const QString &serverId, DockerContainer container, int &statusOut); + + ErrorCode queryMtProxyDiagnostics(const QString &serverId, DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out); + + QString fetchDockerContainerSecret(const QString &serverId, DockerContainer container); + ContainerConfig generateConfig(DockerContainer container, int port, TransportProto transportProto); ErrorCode getAlreadyInstalledContainers(const ServerCredentials &credentials, QMap &installedContainers, SshSession &sshSession); diff --git a/client/core/controllers/serversController.cpp b/client/core/controllers/serversController.cpp index aeedd39f3..7b406ae06 100644 --- a/client/core/controllers/serversController.cpp +++ b/client/core/controllers/serversController.cpp @@ -265,20 +265,6 @@ ContainerConfig ServersController::getContainerConfig(const QString &serverId, D return getServerContainersMap(serverId).value(container); } -void ServersController::updateContainerConfig(const QString &serverId, DockerContainer container, const ContainerConfig &config) -{ - const serverConfigUtils::ConfigType kind = m_serversRepository->serverKind(serverId); - if (kind != serverConfigUtils::ConfigType::SelfHostedAdmin) { - return; - } - auto cfg = m_serversRepository->selfHostedAdminConfig(serverId); - if (!cfg.has_value()) { - return; - } - cfg->updateContainerConfig(container, config); - m_serversRepository->editServer(serverId, cfg->toJson(), kind); -} - int ServersController::getDefaultServerIndex() const { return m_serversRepository->defaultServerIndex(); diff --git a/client/core/controllers/serversController.h b/client/core/controllers/serversController.h index abb0974e9..e8286ed4c 100644 --- a/client/core/controllers/serversController.h +++ b/client/core/controllers/serversController.h @@ -57,7 +57,6 @@ public: QMap getServerContainersMap(const QString &serverId) const; DockerContainer getDefaultContainer(const QString &serverId) const; ContainerConfig getContainerConfig(const QString &serverId, DockerContainer container) const; - void updateContainerConfig(const QString &serverId, DockerContainer container, const ContainerConfig &config); // Validation bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol) const; diff --git a/client/core/installers/mtProxyInstaller.cpp b/client/core/installers/mtProxyInstaller.cpp index 4901df022..21ac5bd4a 100644 --- a/client/core/installers/mtProxyInstaller.cpp +++ b/client/core/installers/mtProxyInstaller.cpp @@ -67,6 +67,54 @@ ErrorCode MtProxyInstaller::extractConfigFromContainer(DockerContainer container return ErrorCode::NoError; } +ErrorCode MtProxyInstaller::queryDiagnostics(SshSession &sshSession, const ServerCredentials &credentials, + DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out) +{ + out = {}; + if (container != DockerContainer::MtProxy) { + return ErrorCode::InternalError; + } + const QString containerName = ContainerUtils::containerToString(container); + const QString script = + QStringLiteral( + "PORT_OK=$(sudo docker exec %1 sh -c 'ss -tlnp 2>/dev/null | grep -q :%2 && echo yes || echo no' 2>/dev/null || echo no); " + "TG_OK=$(curl -s --max-time 5 -o /dev/null -w '%%{http_code}' https://core.telegram.org/getProxySecret 2>/dev/null | grep -q '200' && echo yes || echo no); " + "CLIENTS=$(sudo docker exec amnezia-mtproxy sh -c 'curl -s --max-time 3 http://localhost:2398/stats 2>/dev/null | grep -o \"total_special_connections:[0-9]*\" | cut -d: -f2' 2>/dev/null); " + "CONF_TIME=$(sudo docker exec amnezia-mtproxy sh -c 'stat -c \"%%y\" /data/proxy-multi.conf 2>/dev/null | cut -d. -f1' 2>/dev/null || echo unknown); " + "echo \"PORT_OK=${PORT_OK}\"; " + "echo \"TG_OK=${TG_OK}\"; " + "echo \"CLIENTS=${CLIENTS:-0}\"; " + "echo \"CONF_TIME=${CONF_TIME}\"; " + "echo \"STATS=http://localhost:2398/stats\";") + .arg(containerName) + .arg(listenPort); + + QString stdOut; + auto cbReadStdOut = [&](const QString &data, libssh::Client &) { + stdOut += data; + return ErrorCode::NoError; + }; + const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + if (errorCode != ErrorCode::NoError) { + return errorCode; + } + for (const QString &line : stdOut.split('\n', Qt::SkipEmptyParts)) { + if (line.startsWith(QLatin1String("PORT_OK="))) { + out.portReachable = line.mid(8).trimmed() == QLatin1String("yes"); + } else if (line.startsWith(QLatin1String("TG_OK="))) { + out.upstreamReachable = line.mid(6).trimmed() == QLatin1String("yes"); + } else if (line.startsWith(QLatin1String("CLIENTS="))) { + out.clientsConnected = line.mid(8).trimmed().toInt(); + } else if (line.startsWith(QLatin1String("CONF_TIME="))) { + out.lastConfigRefresh = line.mid(10).trimmed(); + } else if (line.startsWith(QLatin1String("STATS="))) { + out.statsEndpoint = line.mid(6).trimmed(); + } + } + return ErrorCode::NoError; +} + void MtProxyInstaller::uploadClientSettingsSnapshot(SshSession &sshSession, const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config) { const MtProxyProtocolConfig *mt = config.getMtProxyProtocolConfig(); diff --git a/client/core/installers/mtProxyInstaller.h b/client/core/installers/mtProxyInstaller.h index 88da30bd7..2487c9b56 100644 --- a/client/core/installers/mtProxyInstaller.h +++ b/client/core/installers/mtProxyInstaller.h @@ -3,6 +3,16 @@ #include "installerBase.h" +#include + +struct MtProxyContainerDiagnostics { + bool portReachable = false; + bool upstreamReachable = false; + int clientsConnected = -1; + QString lastConfigRefresh; + QString statsEndpoint; +}; + class MtProxyInstaller : public InstallerBase { Q_OBJECT public: @@ -15,6 +25,10 @@ public: static void uploadClientSettingsSnapshot(SshSession &sshSession, const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, const amnezia::ContainerConfig &config); + + static amnezia::ErrorCode queryDiagnostics(SshSession &sshSession, const amnezia::ServerCredentials &credentials, + amnezia::DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out); }; #endif // MTPROXYINSTALLER_H diff --git a/client/ui/controllers/selfhosted/installUiController.cpp b/client/ui/controllers/selfhosted/installUiController.cpp index 739e423ce..6e674f452 100755 --- a/client/ui/controllers/selfhosted/installUiController.cpp +++ b/client/ui/controllers/selfhosted/installUiController.cpp @@ -12,7 +12,6 @@ #include "core/utils/api/apiUtils.h" #include "core/controllers/selfhosted/installController.h" -#include "core/utils/selfhosted/sshSession.h" #include "core/utils/networkUtilities.h" #include "core/utils/protocolEnum.h" #include "core/protocols/protocolUtils.h" @@ -315,26 +314,17 @@ void InstallUiController::updateContainer(const QString &serverId, int container emit installationErrorOccurred(errorCode); } -void InstallUiController::setContainerEnabled(const QString &serverId, int containerIndex, bool enabled) { +void InstallUiController::setContainerEnabled(const QString &serverId, int containerIndex, bool enabled) +{ const DockerContainer container = static_cast(containerIndex); - const ServerCredentials credentials = m_serversController->getServerCredentials(serverId); - const QString containerName = ContainerUtils::containerToString(container); emit serverIsBusy(true); - SshSession sshSession(this); - const QString script = enabled - ? QString("sudo docker start %1").arg(containerName) - : QString("sudo docker stop %1").arg(containerName); - const ErrorCode errorCode = sshSession.runScript(credentials, script); + const ErrorCode errorCode = m_installController->setDockerContainerEnabledState(serverId, container, enabled); emit serverIsBusy(false); if (errorCode == ErrorCode::NoError) { - ContainerConfig currentConfig = m_serversController->getContainerConfig(serverId, container); - if (auto *mtConfig = currentConfig.getMtProxyProtocolConfig()) { - mtConfig->isEnabled = enabled; - m_serversController->updateContainerConfig(serverId, container, currentConfig); - m_protocolModel->updateModel(currentConfig); - } + const ContainerConfig currentConfig = m_serversController->getContainerConfig(serverId, container); + m_protocolModel->updateModel(currentConfig); emit setContainerEnabledFinished(enabled); return; } @@ -342,119 +332,36 @@ void InstallUiController::setContainerEnabled(const QString &serverId, int conta emit installationErrorOccurred(errorCode); } -void InstallUiController::refreshContainerStatus(const QString &serverId, int containerIndex) { +void InstallUiController::refreshContainerStatus(const QString &serverId, int containerIndex) +{ const DockerContainer container = static_cast(containerIndex); - const ServerCredentials credentials = m_serversController->getServerCredentials(serverId); - const QString containerName = ContainerUtils::containerToString(container); - - QString stdOut; - auto cbReadStdOut = [&](const QString &data, libssh::Client &) { - stdOut += data; - return ErrorCode::NoError; - }; - - SshSession sshSession(this); - const QString script = QString( - "sudo docker inspect --format '{{.State.Status}}' %1 2>/dev/null || echo 'not_found'") - .arg(containerName); - const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + int status = 3; + const ErrorCode errorCode = m_installController->queryDockerContainerStatus(serverId, container, status); if (errorCode != ErrorCode::NoError) { emit containerStatusRefreshed(3); return; } - - const QString status = stdOut.trimmed(); - if (status == "running") { - emit containerStatusRefreshed(1); - } else if (status == "not_found" || status.isEmpty()) { - emit containerStatusRefreshed(0); - } else if (status == "exited" || status == "created" || status == "paused") { - emit containerStatusRefreshed(2); - } else { - emit containerStatusRefreshed(3); - } + emit containerStatusRefreshed(status); } -void InstallUiController::refreshContainerDiagnostics(const QString &serverId, int containerIndex, int port) { - const ServerCredentials credentials = m_serversController->getServerCredentials(serverId); +void InstallUiController::refreshContainerDiagnostics(const QString &serverId, int containerIndex, int port) +{ const DockerContainer container = static_cast(containerIndex); - const QString containerName = ContainerUtils::containerToString(container); - - const QString script = - QString( - "PORT_OK=$(sudo docker exec %1 sh -c 'ss -tlnp 2>/dev/null | grep -q :%2 && echo yes || echo no' 2>/dev/null || echo no); " - "TG_OK=$(curl -s --max-time 5 -o /dev/null -w '%%{http_code}' https://core.telegram.org/getProxySecret 2>/dev/null | grep -q '200' && echo yes || echo no); " - "CLIENTS=$(sudo docker exec amnezia-mtproxy sh -c 'curl -s --max-time 3 http://localhost:2398/stats 2>/dev/null | grep -o \"total_special_connections:[0-9]*\" | cut -d: -f2' 2>/dev/null); " - "CONF_TIME=$(sudo docker exec amnezia-mtproxy sh -c 'stat -c \"%%y\" /data/proxy-multi.conf 2>/dev/null | cut -d. -f1' 2>/dev/null || echo unknown); " - "echo \"PORT_OK=${PORT_OK}\"; " - "echo \"TG_OK=${TG_OK}\"; " - "echo \"CLIENTS=${CLIENTS:-0}\"; " - "echo \"CONF_TIME=${CONF_TIME}\"; " - "echo \"STATS=http://localhost:2398/stats\";") - .arg(containerName) - .arg(port); - - QString stdOut; - auto cbReadStdOut = [&](const QString &data, libssh::Client &) { - stdOut += data; - return ErrorCode::NoError; - }; - - SshSession sshSession(this); - const ErrorCode errorCode = sshSession.runScript(credentials, script, cbReadStdOut); + MtProxyContainerDiagnostics diag; + const ErrorCode errorCode = m_installController->queryMtProxyDiagnostics(serverId, container, port, diag); if (errorCode != ErrorCode::NoError) { emit containerDiagnosticsRefreshed(false, false, -1, QString(), QString()); return; } - - bool portReachable = false; - bool upstreamReachable = false; - int clientsConnected = -1; - QString lastConfigRefresh; - QString statsEndpoint; - - for (const QString &line: stdOut.split('\n', Qt::SkipEmptyParts)) { - if (line.startsWith("PORT_OK=")) { - portReachable = line.mid(8).trimmed() == "yes"; - } else if (line.startsWith("TG_OK=")) { - upstreamReachable = line.mid(6).trimmed() == "yes"; - } else if (line.startsWith("CLIENTS=")) { - clientsConnected = line.mid(8).trimmed().toInt(); - } else if (line.startsWith("CONF_TIME=")) { - lastConfigRefresh = line.mid(10).trimmed(); - } else if (line.startsWith("STATS=")) { - statsEndpoint = line.mid(6).trimmed(); - } - } - - emit containerDiagnosticsRefreshed(portReachable, upstreamReachable, clientsConnected, lastConfigRefresh, - statsEndpoint); + emit containerDiagnosticsRefreshed(diag.portReachable, diag.upstreamReachable, diag.clientsConnected, + diag.lastConfigRefresh, diag.statsEndpoint); } -void InstallUiController::fetchContainerSecret(const QString &serverId, int containerIndex) { - const ServerCredentials credentials = m_serversController->getServerCredentials(serverId); +void InstallUiController::fetchContainerSecret(const QString &serverId, int containerIndex) +{ const DockerContainer container = static_cast(containerIndex); - const QString containerName = ContainerUtils::containerToString(container); - - QString stdOut; - auto cbReadStdOut = [&](const QString &data, libssh::Client &) { - stdOut += data; - return ErrorCode::NoError; - }; - - SshSession sshSession(this); - const QString path = QStringLiteral("/data/secret"); - const QString cmd = - QStringLiteral("sudo docker exec %1 cat %2").arg(containerName, path); - const ErrorCode errorCode = sshSession.runScript(credentials, cmd, cbReadStdOut); - if (errorCode != ErrorCode::NoError) { - emit containerSecretFetched(QString()); - return; - } - - const QString secret = stdOut.trimmed(); - static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$")); - emit containerSecretFetched(hex32.match(secret).hasMatch() ? secret : QString()); + const QString secret = m_installController->fetchDockerContainerSecret(serverId, container); + emit containerSecretFetched(secret); } void InstallUiController::rebootServer(const QString &serverId)