diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 342f5c58e..8df10dbde 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,6 @@ jobs: - uses: dorny/paths-filter@v3 id: filter with: - base: ${{ github.event.before }} filters: | recipes: - 'recipes/**' @@ -40,7 +39,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build dependencies' shell: bash @@ -50,9 +49,11 @@ jobs: done - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -98,7 +99,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Install system packages' run: sudo apt-get install libxkbcommon-x11-0 libsecret-1-dev @@ -118,7 +119,7 @@ jobs: - name: 'Upload installer artifact' uses: actions/upload-artifact@v7 with: - path: deploy/build/AmneziaVPN-*-Linux.run + path: deploy/build/AmneziaVPN_*_linux_x64.run archive: false retention-days: 7 @@ -149,15 +150,17 @@ jobs: - uses: ilammy/msvc-dev-cmd@v1 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build dependencies' run: cmake -S . -B build -G "Visual Studio 17 2022" -DPREBUILTS_ONLY=1 - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -229,7 +232,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build project' shell: cmd @@ -242,27 +245,31 @@ jobs: - name: 'Upload WIX installer artifact' uses: actions/upload-artifact@v7 with: - path: deploy/build/AmneziaVPN-*-win64.msi + path: deploy/build/AmneziaVPN_*_windows_x64.msi archive: false retention-days: 7 - name: 'Upload IFW installer artifact' uses: actions/upload-artifact@v7 with: - path: deploy/build/AmneziaVPN-*-win64.exe + path: deploy/build/AmneziaVPN_*_windows_x64.exe archive: false retention-days: 7 # ------------------------------------------------------ Bake-Prebuilts-iOS: - runs-on: macos-latest needs: Detect-Changes if: needs.Detect-Changes.outputs.recipes_changed == 'true' strategy: matrix: - xcode-version: [26.0] + xcode-version: [26.0, 26.4] + include: + - xcode-version: 26.4 + os: macos-26 + + runs-on: ${{ matrix.os || 'macos-latest' }} steps: - uses: actions/checkout@v4 @@ -279,15 +286,17 @@ jobs: xcode-version: ${{ matrix.xcode-version }} - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build dependencies' run: cmake -S . -B build -G Xcode -DPREBUILTS_ONLY=1 -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphoneos - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -344,7 +353,7 @@ jobs: - name: 'Setup xcode' uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.1' + xcode-version: '26.0' - name: 'Install desktop Qt' uses: jurplel/install-qt-action@v3 @@ -376,7 +385,7 @@ jobs: python-version: 3.14 - name: 'Install deps' - run: pip install "conan==2.26.2" jsonschema jinja2 + run: pip install "conan==2.28.0" jsonschema jinja2 - name: 'Build project' env: @@ -394,14 +403,17 @@ jobs: # ------------------------------------------------------ Bake-Prebuilts-MacOS: - runs-on: macos-latest - needs: Detect-Changes if: needs.Detect-Changes.outputs.recipes_changed == 'true' strategy: matrix: - xcode-version: [16.2, 16.4] + xcode-version: [16.2, 16.4, 26.4] + include: + - xcode-version: 26.4 + os: macos-26 + + runs-on: ${{ matrix.os || 'macos-latest' }} steps: - uses: actions/checkout@v4 @@ -418,15 +430,17 @@ jobs: xcode-version: ${{ matrix.xcode-version }} - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build dependencies' run: cmake -S . -B build -G Xcode -DPREBUILTS_ONLY=1 - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -502,7 +516,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build project' env: @@ -518,7 +532,7 @@ jobs: - name: 'Upload installer artifact' uses: actions/upload-artifact@v7 with: - path: deploy/build/AmneziaVPN-*-Darwin.pkg + path: deploy/build/AmneziaVPN_*_macos_x64.pkg archive: false retention-days: 7 @@ -548,15 +562,17 @@ jobs: xcode-version: ${{ matrix.xcode-version }} - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build dependencies' run: cmake -S . -B build -G Xcode -DPREBUILTS_ONLY=1 -DMACOS_NE=TRUE - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -635,7 +651,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Build project' run: | @@ -671,7 +687,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Setup Android SDK' uses: android-actions/setup-android@v4 @@ -696,9 +712,11 @@ jobs: done - name: 'Authorize in remote' + if: github.ref == 'refs/heads/dev' run: conan remote login amnezia "${{ secrets.CONAN_USER }}" -p "${{ secrets.CONAN_PASSWORD }}" - name: 'Upload baked prebuilts' + if: github.ref == 'refs/heads/dev' run: conan upload -r amnezia "*" -c # ------------------------------------------------------ @@ -712,7 +730,7 @@ jobs: env: ANDROID_PLATFORM: android-28 NDK_VERSION: 27.0.11718014 - QT_VERSION: 6.10.1 + QT_VERSION: 6.10.3 QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools' PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }} PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }} @@ -806,7 +824,7 @@ jobs: python-version: 3.14 - name: 'Install conan' - run: pip install "conan==2.26.2" + run: pip install "conan==2.28.0" - name: 'Decode keystore secret to file' env: diff --git a/CMakeLists.txt b/CMakeLists.txt index 11400bf73..c01a6e229 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(PROJECT AmneziaVPN) -set(AMNEZIAVPN_VERSION 4.8.15.4) +set(AMNEZIAVPN_VERSION 4.9.0.0) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES @@ -28,7 +28,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 2120) +set(APP_ANDROID_VERSION_CODE 2122) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index 7c5767549..ddc44d47b 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -35,6 +35,8 @@ 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/installers/telemtInstaller.h ${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.h ${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.h ${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.h @@ -110,6 +112,8 @@ 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/installers/telemtInstaller.cpp ${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.cpp @@ -201,12 +205,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/configurators/xrayConfigurator.cpp b/client/core/configurators/xrayConfigurator.cpp index b525c8991..1d6e2240a 100644 --- a/client/core/configurators/xrayConfigurator.cpp +++ b/client/core/configurators/xrayConfigurator.cpp @@ -20,14 +20,123 @@ #include "core/models/protocols/xrayProtocolConfig.h" namespace { -Logger logger("XrayConfigurator"); -} + Logger logger("XrayConfigurator"); + + QString normalizeXhttpMode(const QString &m) { + const QString t = m.trimmed(); + if (t.isEmpty() || t.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0) { + return QStringLiteral("auto"); + } + if (t.compare(QLatin1String("Packet-up"), Qt::CaseInsensitive) == 0) + return QStringLiteral("packet-up"); + if (t.compare(QLatin1String("Stream-up"), Qt::CaseInsensitive) == 0) + return QStringLiteral("stream-up"); + if (t.compare(QLatin1String("Stream-one"), Qt::CaseInsensitive) == 0) + return QStringLiteral("stream-one"); + return t.toLower(); + } + + // Xray-core: empty → path; "None" in UI → omit (core default path) + QString normalizeSessionSeqPlacement(const QString &p) + { + if (p.isEmpty() || p.compare(QLatin1String("None"), Qt::CaseInsensitive) == 0) + return {}; + return p.toLower(); + } + + QString normalizeUplinkDataPlacement(const QString &p) + { + if (p.isEmpty() || p.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0) + return QStringLiteral("body"); + if (p.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0) + return QStringLiteral("auto"); + if (p.compare(QLatin1String("Query"), Qt::CaseInsensitive) == 0) + // "Query" is not valid for uplink payload in splithttp; closest documented mode + return QStringLiteral("header"); + return p.toLower(); + } + + // splithttp: cookie | header | query | queryInHeader (not "body") + QString normalizeXPaddingPlacement(const QString &p) + { + QString t = p.trimmed(); + if (t.isEmpty()) + return QString::fromLatin1(amnezia::protocols::xray::defaultXPaddingPlacement).toLower(); + if (t.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0) + return QStringLiteral("queryInHeader"); + if (t.contains(QLatin1String("queryInHeader"), Qt::CaseInsensitive) + || t.compare(QLatin1String("Query in header"), Qt::CaseInsensitive) == 0) + return QStringLiteral("queryInHeader"); + return t.toLower(); + } + + // splithttp: repeat-x | tokenish + QString normalizeXPaddingMethod(const QString &m) + { + QString t = m.trimmed(); + if (t.isEmpty() || t.compare(QLatin1String("Repeat-x"), Qt::CaseInsensitive) == 0) + return QStringLiteral("repeat-x"); + if (t.compare(QLatin1String("Tokenish"), Qt::CaseInsensitive) == 0) + return QStringLiteral("tokenish"); + if (t.compare(QLatin1String("Random"), Qt::CaseInsensitive) == 0 + || t.compare(QLatin1String("Zero"), Qt::CaseInsensitive) == 0) + return QStringLiteral("repeat-x"); + return t.toLower(); + } + + void putIntRangeIfAny(QJsonObject &obj, const char *key, QString minV, QString maxV, const char *fallbackMin, + const char *fallbackMax) + { + if (minV.isEmpty() && maxV.isEmpty()) + return; + if (minV.isEmpty()) + minV = QString::fromLatin1(fallbackMin); + if (maxV.isEmpty()) + maxV = QString::fromLatin1(fallbackMax); + QJsonObject r; + r[QStringLiteral("from")] = minV.toInt(); + r[QStringLiteral("to")] = maxV.toInt(); + obj[QString::fromUtf8(key)] = r; + } + + // Desktop applies this in XrayProtocol::start(); iOS/Android pass JSON straight to libxray — same fixes here. + void sanitizeXrayNativeConfig(amnezia::ProtocolConfig &pc) + { + QString c = pc.nativeConfig(); + if (c.isEmpty()) { + return; + } + bool changed = false; + if (c.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + c.replace(QLatin1String("Mozilla/5.0"), QString::fromLatin1(amnezia::protocols::xray::defaultFingerprint), + Qt::CaseInsensitive); + changed = true; + } + const QString legacyListen = QString::fromLatin1(amnezia::protocols::xray::defaultLocalAddr); + const QString listenOk = QString::fromLatin1(amnezia::protocols::xray::defaultLocalListenAddr); + if (c.contains(legacyListen)) { + c.replace(legacyListen, listenOk); + changed = true; + } + if (changed) { + pc.setNativeConfig(c); + } + } +} // namespace XrayConfigurator::XrayConfigurator(SshSession* sshSession, QObject *parent) : ConfiguratorBase(sshSession, parent) { } +amnezia::ProtocolConfig XrayConfigurator::processConfigWithLocalSettings(const amnezia::ConnectionSettings &settings, + amnezia::ProtocolConfig protocolConfig) +{ + applyDnsToNativeConfig(settings.dns, protocolConfig); + sanitizeXrayNativeConfig(protocolConfig); + return protocolConfig; +} + QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &containerConfig, const DnsSettings &dnsSettings, @@ -35,11 +144,19 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia { // Generate new UUID for client QString clientId = QUuid::createUuid().toString(QUuid::WithoutBraces); - + + // Get flow value from settings (default xtls-rprx-vision) + QString flowValue = "xtls-rprx-vision"; + if (const auto *xrayCfg = containerConfig.protocolConfig.as()) { + if (!xrayCfg->serverConfig.flow.isEmpty()) { + flowValue = xrayCfg->serverConfig.flow; + } + } + // Get current server config QString currentConfig = m_sshSession->getTextFileFromContainer( container, credentials, amnezia::protocols::xray::serverConfigPath, errorCode); - + if (errorCode != ErrorCode::NoError) { logger.error() << "Failed to get server config file"; return ""; @@ -54,7 +171,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia } QJsonObject serverConfig = doc.object(); - + // Validate server config structure if (!serverConfig.contains(amnezia::protocols::xray::inbounds)) { logger.error() << "Server config missing 'inbounds' field"; @@ -68,7 +185,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia errorCode = ErrorCode::InternalError; return ""; } - + QJsonObject inbound = inbounds[0].toObject(); if (!inbound.contains(amnezia::protocols::xray::settings)) { logger.error() << "Inbound missing 'settings' field"; @@ -84,26 +201,29 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia } QJsonArray clients = settings[amnezia::protocols::xray::clients].toArray(); - + // Create configuration for new client QJsonObject clientConfig { {amnezia::protocols::xray::id, clientId}, - {amnezia::protocols::xray::flow, "xtls-rprx-vision"} }; - + clientConfig[amnezia::protocols::xray::id] = clientId; + if (!flowValue.isEmpty()) { + clientConfig[amnezia::protocols::xray::flow] = flowValue; + } + clients.append(clientConfig); - + // Update config settings[amnezia::protocols::xray::clients] = clients; inbound[amnezia::protocols::xray::settings] = settings; inbounds[0] = inbound; serverConfig[amnezia::protocols::xray::inbounds] = inbounds; - + // Save updated config to server QString updatedConfig = QJsonDocument(serverConfig).toJson(); errorCode = m_sshSession->uploadTextFileToContainer( - container, - credentials, + container, + credentials, updatedConfig, amnezia::protocols::xray::serverConfigPath, libssh::ScpOverwriteMode::ScpOverwriteExisting @@ -116,7 +236,7 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia // Restart container QString restartScript = QString("sudo docker restart $CONTAINER_NAME"); errorCode = m_sshSession->runScript( - credentials, + credentials, m_sshSession->replaceVars(restartScript, amnezia::genBaseVars(credentials, container, dnsSettings.primaryDns, dnsSettings.secondaryDns)) ); @@ -128,75 +248,286 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia return clientId; } -ProtocolConfig XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, - const ContainerConfig &containerConfig, - const DnsSettings &dnsSettings, - ErrorCode &errorCode) +QJsonObject XrayConfigurator::buildStreamSettings(const XrayServerConfig &srv, const QString &clientId) const { - const XrayServerConfig* serverConfig = nullptr; - if (auto* xrayConfig = containerConfig.protocolConfig.as()) { - serverConfig = &xrayConfig->serverConfig; + QJsonObject streamSettings; + const auto &xhttp = srv.xhttp; + const auto &mkcp = srv.mkcp; + namespace px = amnezia::protocols::xray; + + QString networkValue = QStringLiteral("tcp"); + if (srv.transport == QLatin1String("xhttp")) + networkValue = QStringLiteral("xhttp"); + else if (srv.transport == QLatin1String("mkcp")) + networkValue = QStringLiteral("kcp"); + streamSettings[px::network] = networkValue; + + streamSettings[px::security] = srv.security; + + if (srv.security == QLatin1String("tls")) { + QJsonObject tlsSettings; + const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni; + tlsSettings[px::serverName] = sniEff; + const QString alpnEff = srv.alpn.isEmpty() ? QString::fromLatin1(px::defaultAlpn) : srv.alpn; + QJsonArray alpnArray; + for (const QString &a : alpnEff.split(QLatin1Char(','))) { + const QString t = a.trimmed(); + if (!t.isEmpty()) + alpnArray.append(t); + } + if (!alpnArray.isEmpty()) + tlsSettings[QStringLiteral("alpn")] = alpnArray; + const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint; + tlsSettings[px::fingerprint] = fpEff; + streamSettings[QStringLiteral("tlsSettings")] = tlsSettings; } - + + if (srv.security == QLatin1String("reality")) { + QJsonObject realSettings; + const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint; + realSettings[px::fingerprint] = fpEff; + const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni; + realSettings[px::serverName] = sniEff; + streamSettings[px::realitySettings] = realSettings; + } + + // XHTTP — JSON must match Xray-core SplitHTTPConfig (flat xPadding fields, see transport_internet.go) + if (srv.transport == QLatin1String("xhttp")) { + QJsonObject xo; + const QString hostEff = xhttp.host.isEmpty() ? QString::fromLatin1(px::defaultXhttpHost) : xhttp.host; + xo[QStringLiteral("host")] = hostEff; + if (!xhttp.path.isEmpty()) + xo[QStringLiteral("path")] = xhttp.path; + xo[QStringLiteral("mode")] = normalizeXhttpMode(xhttp.mode); + + if (xhttp.headersTemplate.compare(QLatin1String("HTTP"), Qt::CaseInsensitive) == 0) { + QJsonObject headers; + headers[QStringLiteral("Host")] = hostEff; + xo[QStringLiteral("headers")] = headers; + } + + const QString methodEff = + xhttp.uplinkMethod.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkMethod) : xhttp.uplinkMethod; + xo[QStringLiteral("uplinkHTTPMethod")] = methodEff.toUpper(); + + xo[QStringLiteral("noGRPCHeader")] = xhttp.disableGrpc; + xo[QStringLiteral("noSSEHeader")] = xhttp.disableSse; + + const QString sessPl = normalizeSessionSeqPlacement(xhttp.sessionPlacement); + if (!sessPl.isEmpty()) + xo[QStringLiteral("sessionPlacement")] = sessPl; + const QString seqPl = normalizeSessionSeqPlacement(xhttp.seqPlacement); + if (!seqPl.isEmpty()) + xo[QStringLiteral("seqPlacement")] = seqPl; + if (!xhttp.sessionKey.isEmpty()) + xo[QStringLiteral("sessionKey")] = xhttp.sessionKey; + if (!xhttp.seqKey.isEmpty()) + xo[QStringLiteral("seqKey")] = xhttp.seqKey; + + xo[QStringLiteral("uplinkDataPlacement")] = normalizeUplinkDataPlacement(xhttp.uplinkDataPlacement); + if (!xhttp.uplinkDataKey.isEmpty()) + xo[QStringLiteral("uplinkDataKey")] = xhttp.uplinkDataKey; + + const QString ucs = xhttp.uplinkChunkSize.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkChunkSize) + : xhttp.uplinkChunkSize; + if (!ucs.isEmpty() && ucs != QLatin1String("0")) { + const int v = ucs.toInt(); + QJsonObject chunkR; + chunkR[QStringLiteral("from")] = v; + chunkR[QStringLiteral("to")] = v; + xo[QStringLiteral("uplinkChunkSize")] = chunkR; + } + + if (!xhttp.scMaxBufferedPosts.isEmpty()) + xo[QStringLiteral("scMaxBufferedPosts")] = xhttp.scMaxBufferedPosts.toLongLong(); + + putIntRangeIfAny(xo, "scMaxEachPostBytes", xhttp.scMaxEachPostBytesMin, xhttp.scMaxEachPostBytesMax, + px::defaultXhttpScMaxEachPostBytesMin, px::defaultXhttpScMaxEachPostBytesMax); + putIntRangeIfAny(xo, "scMinPostsIntervalMs", xhttp.scMinPostsIntervalMsMin, xhttp.scMinPostsIntervalMsMax, + px::defaultXhttpScMinPostsIntervalMsMin, px::defaultXhttpScMinPostsIntervalMsMax); + putIntRangeIfAny(xo, "scStreamUpServerSecs", xhttp.scStreamUpServerSecsMin, xhttp.scStreamUpServerSecsMax, + px::defaultXhttpScStreamUpServerSecsMin, px::defaultXhttpScStreamUpServerSecsMax); + + const auto &pad = xhttp.xPadding; + xo[QStringLiteral("xPaddingObfsMode")] = pad.obfsMode; + if (pad.obfsMode) { + if (!pad.bytesMin.isEmpty() || !pad.bytesMax.isEmpty()) { + QJsonObject br; + br[QStringLiteral("from")] = pad.bytesMin.isEmpty() ? 1 : pad.bytesMin.toInt(); + br[QStringLiteral("to")] = pad.bytesMax.isEmpty() ? (pad.bytesMin.isEmpty() ? 256 : pad.bytesMin.toInt()) + : pad.bytesMax.toInt(); + xo[QStringLiteral("xPaddingBytes")] = br; + } + xo[QStringLiteral("xPaddingKey")] = pad.key.isEmpty() ? QStringLiteral("x_padding") : pad.key; + xo[QStringLiteral("xPaddingHeader")] = pad.header.isEmpty() ? QStringLiteral("X-Padding") : pad.header; + xo[QStringLiteral("xPaddingPlacement")] = normalizeXPaddingPlacement( + pad.placement.isEmpty() ? QString::fromLatin1(px::defaultXPaddingPlacement) : pad.placement); + xo[QStringLiteral("xPaddingMethod")] = normalizeXPaddingMethod( + pad.method.isEmpty() ? QString::fromLatin1(px::defaultXPaddingMethod) : pad.method); + } + + // xmux: Xray has no "enabled" flag; omit object when UI disables multiplex tuning. + if (xhttp.xmux.enabled) { + QJsonObject mux; + auto addMuxRange = [&](const char *key, const QString &a, const QString &b) { + if (a.isEmpty() && b.isEmpty()) + return; + QJsonObject r; + r[QStringLiteral("from")] = a.isEmpty() ? 0 : a.toInt(); + r[QStringLiteral("to")] = b.isEmpty() ? 0 : b.toInt(); + mux[QString::fromUtf8(key)] = r; + }; + addMuxRange("maxConcurrency", xhttp.xmux.maxConcurrencyMin, xhttp.xmux.maxConcurrencyMax); + addMuxRange("maxConnections", xhttp.xmux.maxConnectionsMin, xhttp.xmux.maxConnectionsMax); + addMuxRange("cMaxReuseTimes", xhttp.xmux.cMaxReuseTimesMin, xhttp.xmux.cMaxReuseTimesMax); + addMuxRange("hMaxRequestTimes", xhttp.xmux.hMaxRequestTimesMin, xhttp.xmux.hMaxRequestTimesMax); + addMuxRange("hMaxReusableSecs", xhttp.xmux.hMaxReusableSecsMin, xhttp.xmux.hMaxReusableSecsMax); + if (!xhttp.xmux.hKeepAlivePeriod.isEmpty()) + mux[QStringLiteral("hKeepAlivePeriod")] = xhttp.xmux.hKeepAlivePeriod.toLongLong(); + if (!mux.isEmpty()) + xo[QStringLiteral("xmux")] = mux; + } + + streamSettings[QStringLiteral("xhttpSettings")] = xo; + } + + if (srv.transport == QLatin1String("mkcp")) { + QJsonObject kcpObj; + const QString ttiEff = mkcp.tti.isEmpty() ? QString::fromLatin1(px::defaultMkcpTti) : mkcp.tti; + const QString upEff = mkcp.uplinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpUplinkCapacity) + : mkcp.uplinkCapacity; + const QString downEff = mkcp.downlinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpDownlinkCapacity) + : mkcp.downlinkCapacity; + const QString rbufEff = mkcp.readBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpReadBufferSize) + : mkcp.readBufferSize; + const QString wbufEff = mkcp.writeBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpWriteBufferSize) + : mkcp.writeBufferSize; + kcpObj[QStringLiteral("tti")] = ttiEff.toInt(); + kcpObj[QStringLiteral("uplinkCapacity")] = upEff.toInt(); + kcpObj[QStringLiteral("downlinkCapacity")] = downEff.toInt(); + kcpObj[QStringLiteral("readBufferSize")] = rbufEff.toInt(); + kcpObj[QStringLiteral("writeBufferSize")] = wbufEff.toInt(); + kcpObj[QStringLiteral("congestion")] = mkcp.congestion; + streamSettings[QStringLiteral("kcpSettings")] = kcpObj; + } + + return streamSettings; +} + +ProtocolConfig XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, + const ContainerConfig &containerConfig, + const DnsSettings &dnsSettings, + ErrorCode &errorCode) +{ + const XrayServerConfig *serverConfig = nullptr; + if (const auto *xrayCfg = containerConfig.protocolConfig.as()) { + serverConfig = &xrayCfg->serverConfig; + } + + if (!serverConfig) { + logger.error() << "No XrayProtocolConfig found"; + errorCode = ErrorCode::InternalError; + return XrayProtocolConfig{}; + } + + const XrayServerConfig &srv = *serverConfig; + QString xrayClientId = prepareServerConfig(credentials, container, containerConfig, dnsSettings, errorCode); if (errorCode != ErrorCode::NoError || xrayClientId.isEmpty()) { logger.error() << "Failed to prepare server config"; - errorCode = ErrorCode::InternalError; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } return XrayProtocolConfig{}; } - amnezia::ScriptVars vars = amnezia::genBaseVars(credentials, container, dnsSettings.primaryDns, dnsSettings.secondaryDns); - vars.append(amnezia::genProtocolVarsForContainer(container, containerConfig)); - QString config = m_sshSession->replaceVars(amnezia::scriptData(ProtocolScriptType::xray_template, container), vars); - - if (config.isEmpty()) { - logger.error() << "Failed to get config template"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; + // Fetch server keys (Reality only) + QString xrayPublicKey; + QString xrayShortId; + + if (srv.security == "reality") { + xrayPublicKey = m_sshSession->getTextFileFromContainer(container, credentials, + amnezia::protocols::xray::PublicKeyPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayPublicKey.isEmpty()) { + logger.error() << "Failed to get public key"; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } + return XrayProtocolConfig{}; + } + xrayPublicKey.replace("\n", ""); + + xrayShortId = m_sshSession->getTextFileFromContainer(container, credentials, + amnezia::protocols::xray::shortidPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayShortId.isEmpty()) { + logger.error() << "Failed to get short ID"; + if (errorCode == ErrorCode::NoError) { + errorCode = ErrorCode::InternalError; + } + return XrayProtocolConfig{}; + } + xrayShortId.replace("\n", ""); } - QString xrayPublicKey = - m_sshSession->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::PublicKeyPath, errorCode); - if (errorCode != ErrorCode::NoError || xrayPublicKey.isEmpty()) { - logger.error() << "Failed to get public key"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; - } - xrayPublicKey.replace("\n", ""); - - QString xrayShortId = - m_sshSession->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::shortidPath, errorCode); - if (errorCode != ErrorCode::NoError || xrayShortId.isEmpty()) { - logger.error() << "Failed to get short ID"; - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; - } - xrayShortId.replace("\n", ""); - - if (!config.contains("$XRAY_CLIENT_ID") || !config.contains("$XRAY_PUBLIC_KEY") || !config.contains("$XRAY_SHORT_ID")) { - logger.error() << "Config template missing required variables:" - << "XRAY_CLIENT_ID:" << !config.contains("$XRAY_CLIENT_ID") - << "XRAY_PUBLIC_KEY:" << !config.contains("$XRAY_PUBLIC_KEY") - << "XRAY_SHORT_ID:" << !config.contains("$XRAY_SHORT_ID"); - errorCode = ErrorCode::InternalError; - return XrayProtocolConfig{}; + // Build outbound + QJsonObject userObj; + userObj[amnezia::protocols::xray::id] = xrayClientId; + userObj[amnezia::protocols::xray::encryption] = "none"; + if (!srv.flow.isEmpty()) { + userObj[amnezia::protocols::xray::flow] = srv.flow; } - config.replace("$XRAY_CLIENT_ID", xrayClientId); - config.replace("$XRAY_PUBLIC_KEY", xrayPublicKey); - config.replace("$XRAY_SHORT_ID", xrayShortId); + QJsonObject vnextEntry; + vnextEntry[amnezia::protocols::xray::address] = credentials.hostName; + vnextEntry[amnezia::protocols::xray::port] = srv.port.toInt(); + vnextEntry[amnezia::protocols::xray::users] = QJsonArray { userObj }; + QJsonObject outboundSettings; + outboundSettings[amnezia::protocols::xray::vnext] = QJsonArray { vnextEntry }; + + QJsonObject outbound; + outbound["protocol"] = "vless"; + outbound[amnezia::protocols::xray::settings] = outboundSettings; + + // Build streamSettings + QJsonObject streamObj = buildStreamSettings(srv, xrayClientId); + + // Inject Reality keys + if (srv.security == "reality") { + QJsonObject rs = streamObj[amnezia::protocols::xray::realitySettings].toObject(); + rs[amnezia::protocols::xray::publicKey] = xrayPublicKey; + rs[amnezia::protocols::xray::shortId] = xrayShortId; + rs[amnezia::protocols::xray::spiderX] = ""; + streamObj[amnezia::protocols::xray::realitySettings] = rs; + } + + outbound[amnezia::protocols::xray::streamSettings] = streamObj; + + // Build full client config + QJsonObject inboundObj; + inboundObj["listen"] = amnezia::protocols::xray::defaultLocalListenAddr; + inboundObj[amnezia::protocols::xray::port] = amnezia::protocols::xray::defaultLocalProxyPort; + inboundObj["protocol"] = "socks"; + inboundObj[amnezia::protocols::xray::settings] = QJsonObject { { "udp", true } }; + + QJsonObject clientJson; + clientJson["log"] = QJsonObject { { "loglevel", "error" } }; + clientJson[amnezia::protocols::xray::inbounds] = QJsonArray { inboundObj }; + clientJson[amnezia::protocols::xray::outbounds] = QJsonArray { outbound }; + + QString config = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact)); + + // Return XrayProtocolConfig protocolConfig; - if (serverConfig) { - protocolConfig.serverConfig = *serverConfig; - } - + protocolConfig.serverConfig = srv; + XrayClientConfig clientConfig; clientConfig.nativeConfig = config; - clientConfig.localPort = ""; + qDebug() << "config:" << config; + clientConfig.localPort = QString(amnezia::protocols::xray::defaultLocalProxyPort); clientConfig.id = xrayClientId; - + protocolConfig.setClientConfig(clientConfig); - + return protocolConfig; -} +} \ No newline at end of file diff --git a/client/core/configurators/xrayConfigurator.h b/client/core/configurators/xrayConfigurator.h index 74a0ea006..968e85b44 100644 --- a/client/core/configurators/xrayConfigurator.h +++ b/client/core/configurators/xrayConfigurator.h @@ -2,11 +2,13 @@ #define XRAY_CONFIGURATOR_H #include +#include #include "configuratorBase.h" #include "core/utils/errorCodes.h" #include "core/utils/routeModes.h" #include "core/utils/commonStructs.h" +#include "core/models/protocols/xrayProtocolConfig.h" class XrayConfigurator : public ConfiguratorBase { @@ -18,10 +20,17 @@ public: const amnezia::DnsSettings &dnsSettings, amnezia::ErrorCode &errorCode) override; + amnezia::ProtocolConfig processConfigWithLocalSettings(const amnezia::ConnectionSettings &settings, + amnezia::ProtocolConfig protocolConfig) override; + private: QString prepareServerConfig(const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, const amnezia::ContainerConfig &containerConfig, const amnezia::DnsSettings &dnsSettings, amnezia::ErrorCode &errorCode); + + // Builds the native xray "streamSettings" JSON object from XrayServerConfig + QJsonObject buildStreamSettings(const amnezia::XrayServerConfig &srv, + const QString &clientId) const; }; #endif // XRAY_CONFIGURATOR_H diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index 8efc14558..39b30add2 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -460,6 +460,7 @@ ErrorCode SubscriptionController::updateServiceFromGateway(const QString &server if (apiV2->nameOverriddenByUser) { newApiV2->name = apiV2->name; + newApiV2->displayName = apiV2->displayName; newApiV2->nameOverriddenByUser = true; } diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index b23f98180..77b951f9e 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -86,6 +86,9 @@ void CoreController::initModels() m_xrayConfigModel = new XrayConfigModel(this); setQmlContextProperty("XrayConfigModel", m_xrayConfigModel); + m_xrayConfigSnapshotsModel = new XrayConfigSnapshotsModel(m_appSettingsRepository, m_xrayConfigModel, this); + setQmlContextProperty("XrayConfigSnapshotsModel", m_xrayConfigSnapshotsModel); + m_torConfigModel = new TorConfigModel(this); setQmlContextProperty("TorConfigModel", m_torConfigModel); @@ -100,6 +103,12 @@ void CoreController::initModels() m_socks5ConfigModel = new Socks5ProxyConfigModel(this); setQmlContextProperty("Socks5ProxyConfigModel", m_socks5ConfigModel); + m_mtProxyConfigModel = new MtProxyConfigModel(this); + setQmlContextProperty("MtProxyConfigModel", m_mtProxyConfigModel); + + m_telemtConfigModel = new TelemtConfigModel(this); + setQmlContextProperty("TelemtConfigModel", m_telemtConfigModel); + m_clientManagementModel = new ClientManagementModel(this); setQmlContextProperty("ClientManagementModel", m_clientManagementModel); @@ -169,7 +178,7 @@ void CoreController::initControllers() #ifdef Q_OS_WINDOWS m_ikev2ConfigModel, #endif - m_sftpConfigModel, m_socks5ConfigModel, this); + m_sftpConfigModel, m_socks5ConfigModel, m_mtProxyConfigModel, m_telemtConfigModel, this); setQmlContextProperty("InstallController", m_installUiController); m_importController = new ImportUiController(m_importCoreController, this); @@ -202,6 +211,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 4b3ed2831..70033d61b 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" @@ -64,11 +65,15 @@ #include "ui/models/protocols/openvpnConfigModel.h" #include "ui/models/protocols/wireguardConfigModel.h" #include "ui/models/protocols/xrayConfigModel.h" +#include "ui/models/protocols/xrayConfigSnapshotsModel.h" #include "ui/models/protocolsModel.h" #include "ui/models/services/torConfigModel.h" #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/services/telemtConfigModel.h" + #include "ui/models/ipSplitTunnelingModel.h" #include "ui/models/newsModel.h" @@ -156,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; @@ -200,6 +206,7 @@ private: OpenVpnConfigModel* m_openVpnConfigModel; XrayConfigModel* m_xrayConfigModel; + XrayConfigSnapshotsModel* m_xrayConfigSnapshotsModel; TorConfigModel* m_torConfigModel; WireGuardConfigModel* m_wireGuardConfigModel; AwgConfigModel* m_awgConfigModel; @@ -208,6 +215,8 @@ private: #endif SftpConfigModel* m_sftpConfigModel; Socks5ProxyConfigModel* m_socks5ConfigModel; + MtProxyConfigModel* m_mtProxyConfigModel; + TelemtConfigModel* m_telemtConfigModel; CoreSignalHandlers* m_signalHandlers; }; diff --git a/client/core/controllers/selfhosted/exportController.cpp b/client/core/controllers/selfhosted/exportController.cpp index 095b57353..75faccf20 100644 --- a/client/core/controllers/selfhosted/exportController.cpp +++ b/client/core/controllers/selfhosted/exportController.cpp @@ -323,6 +323,18 @@ ExportController::ExportResult ExportController::generateXrayConfig(const QStrin vlessServer.shortId = realitySettings.value(amnezia::protocols::xray::shortId).toString(); vlessServer.fingerprint = realitySettings.value(amnezia::protocols::xray::fingerprint).toString("chrome"); vlessServer.spiderX = realitySettings.value(amnezia::protocols::xray::spiderX).toString(""); + } else if (vlessServer.security == "tls") { + QJsonObject tlsSettings = streamSettings.value("tlsSettings").toObject(); + vlessServer.serverName = tlsSettings.value(amnezia::protocols::xray::serverName).toString(); + vlessServer.fingerprint = tlsSettings.value(amnezia::protocols::xray::fingerprint).toString(); + // alpn: serialize array back to comma-separated for VLESS URI + QJsonArray alpnArr = tlsSettings.value("alpn").toArray(); + QStringList alpnList; + for (const QJsonValue &v : alpnArr) { + alpnList << v.toString(); + } + // alpn goes into vless URI query param — handled by Serialize via serverName/alpn fields + // VlessServerObject doesn't have alpn field, so we embed in serverName if needed } result.nativeConfigString = amnezia::serialization::vless::Serialize(vlessServer, "AmneziaVPN"); diff --git a/client/core/controllers/selfhosted/installController.cpp b/client/core/controllers/selfhosted/installController.cpp index f4c318161..62973fed7 100644 --- a/client/core/controllers/selfhosted/installController.cpp +++ b/client/core/controllers/selfhosted/installController.cpp @@ -19,6 +19,8 @@ #include "core/installers/openvpnInstaller.h" #include "core/installers/sftpInstaller.h" #include "core/installers/socks5Installer.h" +#include "core/installers/mtProxyInstaller.h" +#include "core/installers/telemtInstaller.h" #include "core/installers/torInstaller.h" #include "core/installers/wireguardInstaller.h" #include "core/installers/xrayInstaller.h" @@ -34,6 +36,7 @@ #include "core/utils/constants/configKeys.h" #include "core/utils/constants/protocolConstants.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" @@ -53,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, @@ -136,6 +154,15 @@ ErrorCode InstallController::updateContainer(const QString &serverId, DockerCont if (!adminConfig.has_value()) { return ErrorCode::InternalError; } + if (container == DockerContainer::MtProxy) { + ServerCredentials credentials = adminConfig->credentials(); + SshSession sshSession(this); + MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } else if (container == DockerContainer::Telemt) { + ServerCredentials credentials = adminConfig->credentials(); + SshSession sshSession(this); + TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } adminConfig->updateContainerConfig(container, newConfig); m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin); return ErrorCode::NoError; @@ -165,6 +192,11 @@ ErrorCode InstallController::updateContainer(const QString &serverId, DockerCont } if (errorCode == ErrorCode::NoError) { + if (container == DockerContainer::MtProxy) { + MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } else if (container == DockerContainer::Telemt) { + TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig); + } clearCachedProfile(serverId, container); adminConfig->updateContainerConfig(container, newConfig); m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin); @@ -194,38 +226,75 @@ void InstallController::clearCachedProfile(const QString &serverId, DockerContai ErrorCode InstallController::validateAndPrepareConfig(const QString &serverId) { - auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId); - if (!adminConfig.has_value()) { + const auto kind = m_serversRepository->serverKind(serverId); + + DockerContainer container = DockerContainer::None; + ContainerConfig containerConfig; + + switch (kind) { + case serverConfigUtils::ConfigType::SelfHostedAdmin: { + const auto cfg = m_serversRepository->selfHostedAdminConfig(serverId); + if (!cfg.has_value()) { + return ErrorCode::InternalError; + } + container = cfg->defaultContainer; + containerConfig = cfg->containerConfig(container); + break; + } + case serverConfigUtils::ConfigType::SelfHostedUser: { + const auto cfg = m_serversRepository->selfHostedUserConfig(serverId); + if (!cfg.has_value()) { + return ErrorCode::InternalError; + } + container = cfg->defaultContainer; + containerConfig = cfg->containerConfig(container); + break; + } + case serverConfigUtils::ConfigType::Native: { + const auto cfg = m_serversRepository->nativeConfig(serverId); + if (!cfg.has_value()) { + return ErrorCode::InternalError; + } + container = cfg->defaultContainer; + containerConfig = cfg->containerConfig(container); + break; + } + default: return ErrorCode::InternalError; } - DockerContainer container = adminConfig->defaultContainer; - if (container == DockerContainer::None) { return ErrorCode::NoInstalledContainersError; } - ContainerConfig containerConfig = adminConfig->containerConfig(container); + if (containerConfig.protocolConfig.hasClientConfig()) { + return ErrorCode::NoError; + } + + if (kind != serverConfigUtils::ConfigType::SelfHostedAdmin) { + return ErrorCode::InternalError; + } + + 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; - - auto isProtocolConfigExists = [](const ContainerConfig &cfg) { - return cfg.protocolConfig.hasClientConfig(); - }; - - if (!isProtocolConfigExists(containerConfig)) { - QString clientName = QString("Admin [%1]").arg(QSysInfo::prettyProductName()); - ErrorCode errorCode = processContainerForAdmin(container, containerConfig, credentials, sshSession, serverId, clientName); - if (errorCode != ErrorCode::NoError) { - return errorCode; - } - adminConfig->updateContainerConfig(container, containerConfig); - m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin); + const QString clientName = QString("Admin [%1]").arg(QSysInfo::prettyProductName()); + const ErrorCode errorCode = processContainerForAdmin(container, containerConfig, credentials, sshSession, serverId, clientName); + if (errorCode != ErrorCode::NoError) { + return errorCode; } + adminConfig->updateContainerConfig(container, containerConfig); + m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin); + return ErrorCode::NoError; } @@ -408,9 +477,24 @@ 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); + } else if (container == DockerContainer::Telemt) { + TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, config); + } + + return ErrorCode::NoError; } ErrorCode InstallController::startupContainerWorker(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config, SshSession &sshSession) @@ -563,6 +647,79 @@ 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::Telemt) { + const auto *oldT = oldConfig.getTelemtProtocolConfig(); + const auto *newT = newConfig.getTelemtProtocolConfig(); + if (oldT && newT) { + const QString oldPort = + oldT->port.isEmpty() ? QString(protocols::telemt::defaultPort) : oldT->port; + const QString newPort = + newT->port.isEmpty() ? QString(protocols::telemt::defaultPort) : newT->port; + if (oldPort != newPort) { + return true; + } + const QString oldTransport = oldT->transportMode.isEmpty() + ? QString(protocols::telemt::transportModeStandard) + : oldT->transportMode; + const QString newTransport = newT->transportMode.isEmpty() + ? QString(protocols::telemt::transportModeStandard) + : newT->transportMode; + if (oldTransport != newTransport) { + return true; + } + if (oldT->tlsDomain != newT->tlsDomain) { + return true; + } + if (oldT->maskEnabled != newT->maskEnabled) { + return true; + } + if (oldT->tlsEmulation != newT->tlsEmulation) { + return true; + } + if (oldT->useMiddleProxy != newT->useMiddleProxy) { + return true; + } + if (oldT->tag != newT->tag) { + return true; + } + const QString oldUser = oldT->userName.isEmpty() + ? QString::fromUtf8(protocols::telemt::defaultUserName) + : oldT->userName; + const QString newUser = newT->userName.isEmpty() + ? QString::fromUtf8(protocols::telemt::defaultUserName) + : newT->userName; + if (oldUser != newUser) { + return true; + } + } + } + if (container == DockerContainer::Socks5Proxy) { return true; } @@ -823,6 +980,8 @@ 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)); + case DockerContainer::Telemt: return QScopedPointer(new TelemtInstaller(this)); default: return QScopedPointer(new InstallerBase(this)); } } @@ -861,6 +1020,20 @@ 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); + } else if (container == DockerContainer::Telemt) { + const auto *oldT = oldConfig.getTelemtProtocolConfig(); + const auto *newT = newConfig.getTelemtProtocolConfig(); + if (!oldT || !newT) { + return true; + } + return !oldT->equalsDockerDeploymentSettings(*newT); } return true; @@ -1164,6 +1337,56 @@ 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); + } + } + } else if (container == DockerContainer::Telemt) { + if (auto *telemtConfig = containerConfig.getTelemtProtocolConfig()) { + qDebug() << "amnezia-telemt configure stdout" << 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()) { + telemtConfig->secret = mSecret.captured(1); + } + if (mTgLink.hasMatch()) { + telemtConfig->tgLink = mTgLink.captured(1); + } + if (mTmeLink.hasMatch()) { + telemtConfig->tmeLink = mTmeLink.captured(1); + } + } } } @@ -1248,3 +1471,126 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia return ErrorCode::NoError; } + +ErrorCode InstallController::setDockerContainerEnabledState(const QString &serverId, DockerContainer container, bool enabled) +{ + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return ErrorCode::InternalError; + } + 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); + bool persist = false; + if (auto *mtConfig = currentConfig.getMtProxyProtocolConfig()) { + mtConfig->isEnabled = enabled; + persist = true; + } else if (auto *telemtConfig = currentConfig.getTelemtProtocolConfig()) { + telemtConfig->isEnabled = enabled; + persist = true; + } + if (persist) { + 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) +{ + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return {}; + } + 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 7b406ae06..e7b71fd4a 100644 --- a/client/core/controllers/serversController.cpp +++ b/client/core/controllers/serversController.cpp @@ -44,6 +44,7 @@ bool ServersController::renameServer(const QString &serverId, const QString &nam auto cfg = m_serversRepository->selfHostedAdminConfig(serverId); if (!cfg.has_value()) return false; cfg->description = name; + cfg->displayName = name; m_serversRepository->editServer(serverId, cfg->toJson(), kind); return true; } @@ -51,6 +52,7 @@ bool ServersController::renameServer(const QString &serverId, const QString &nam auto cfg = m_serversRepository->selfHostedUserConfig(serverId); if (!cfg.has_value()) return false; cfg->description = name; + cfg->displayName = name; m_serversRepository->editServer(serverId, cfg->toJson(), kind); return true; } @@ -58,6 +60,7 @@ bool ServersController::renameServer(const QString &serverId, const QString &nam auto cfg = m_serversRepository->nativeConfig(serverId); if (!cfg.has_value()) return false; cfg->description = name; + cfg->displayName = name; m_serversRepository->editServer(serverId, cfg->toJson(), kind); return true; } @@ -67,6 +70,7 @@ bool ServersController::renameServer(const QString &serverId, const QString &nam auto cfg = m_serversRepository->apiV2Config(serverId); if (!cfg.has_value()) return false; cfg->name = name; + cfg->displayName = name; cfg->nameOverriddenByUser = true; m_serversRepository->editServer(serverId, cfg->toJson(), kind); return true; diff --git a/client/core/controllers/updateController.cpp b/client/core/controllers/updateController.cpp index de7106c00..371c3ab3c 100644 --- a/client/core/controllers/updateController.cpp +++ b/client/core/controllers/updateController.cpp @@ -21,13 +21,13 @@ namespace Logger logger("UpdateController"); #if defined(Q_OS_WINDOWS) - const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN-%1-win64.exe"); + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_windows_x64.exe"); const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN_installer.exe"; -#elif defined(Q_OS_MACOS) - const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN-%1-Darwin.pkg"); +#elif defined(Q_OS_MACOS) && !defined(MACOS_NE) + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_macos_x64.pkg"); const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.pkg"; #elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) - const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN-%1-Linux.run"); + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_linux_x64.run"); const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.run"; #endif } @@ -106,7 +106,7 @@ void UpdateController::fetchGatewayUrl() // Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.) QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() { gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload) - .then(this, [this](QPair result) { + .then(this, [this, gatewayController](QPair result) { auto [err, gatewayResponse] = result; if (err != ErrorCode::NoError) { logger.error() << errorString(err); @@ -184,7 +184,7 @@ void UpdateController::setupNetworkErrorHandling(QNetworkReply* reply, const QSt logger.error() << QString("Network error occurred while fetching %1: %2 %3") .arg(operation, reply->errorString(), QString::number(error)); }); - + QObject::connect(reply, &QNetworkReply::sslErrors, [operation](const QList &errors) { QStringList errorStrings; for (const QSslError &err : errors) { @@ -196,21 +196,13 @@ void UpdateController::setupNetworkErrorHandling(QNetworkReply* reply, const QSt void UpdateController::handleNetworkError(QNetworkReply* reply, const QString& operation) { - if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError - || reply->error() == QNetworkReply::NetworkError::TimeoutError) { - logger.error() << errorString(ErrorCode::ApiConfigTimeoutError); - } else { - QString err = reply->errorString(); - logger.error() << "Network error code:" << QString::number(static_cast(reply->error())); - logger.error() << "Error message:" << err; - logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - logger.error() << errorString(ErrorCode::ApiConfigDownloadError); - } + logger.error() << "Network error code:" << QString::number(static_cast(reply->error())); + logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); } QString UpdateController::composeDownloadUrl() const { -#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) const QString fileName = QString(kInstallerRemoteFileNamePattern).arg(m_version); return m_baseUrl + "/" + fileName; #else @@ -220,7 +212,7 @@ QString UpdateController::composeDownloadUrl() const void UpdateController::runInstaller() { -#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) if (m_downloadUrl.isEmpty()) { logger.error() << "Download URL is empty"; return; @@ -252,7 +244,7 @@ void UpdateController::runInstaller() #if defined(Q_OS_WINDOWS) runWindowsInstaller(kInstallerLocalPath); - #elif defined(Q_OS_MACOS) + #elif defined(Q_OS_MACOS) && !defined(MACOS_NE) runMacInstaller(kInstallerLocalPath); #elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) runLinuxInstaller(kInstallerLocalPath); @@ -292,7 +284,7 @@ int UpdateController::runWindowsInstaller(const QString &installerPath) } #endif -#if defined(Q_OS_MACOS) +#if defined(Q_OS_MACOS) && !defined(MACOS_NE) int UpdateController::runMacInstaller(const QString &installerPath) { // Create temporary directory for extraction 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/diagnostics/telemtDiagnostics.h b/client/core/diagnostics/telemtDiagnostics.h new file mode 100644 index 000000000..d2860d299 --- /dev/null +++ b/client/core/diagnostics/telemtDiagnostics.h @@ -0,0 +1,20 @@ +#ifndef TELEMTDIAGNOSTICS_H +#define TELEMTDIAGNOSTICS_H + +#include "containerDiagnostics.h" + +#include + +namespace amnezia +{ + struct TelemtDiagnostics : ContainerDiagnostics + { + bool upstreamReachable = false; + int clientsConnected = -1; + QString lastConfigRefresh; + QString statsEndpoint; + }; + +} // namespace amnezia + +#endif // TELEMTDIAGNOSTICS_H diff --git a/client/core/installers/installerBase.cpp b/client/core/installers/installerBase.cpp index 5bf9caf05..2dc08e85a 100644 --- a/client/core/installers/installerBase.cpp +++ b/client/core/installers/installerBase.cpp @@ -14,6 +14,8 @@ #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/telemtProtocolConfig.h" #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/torProtocolConfig.h" @@ -91,6 +93,18 @@ 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::Telemt: { + TelemtProtocolConfig telemtConfig; + telemtConfig.port = portStr; + config.protocolConfig = telemtConfig; + 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..937dab0db --- /dev/null +++ b/client/core/installers/mtProxyInstaller.cpp @@ -0,0 +1,130 @@ +#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; +} + +ErrorCode MtProxyInstaller::queryDiagnostics(SshSession &sshSession, const ServerCredentials &credentials, + DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out) +{ + out = {}; + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + 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(); + 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..2487c9b56 --- /dev/null +++ b/client/core/installers/mtProxyInstaller.h @@ -0,0 +1,34 @@ +#ifndef MTPROXYINSTALLER_H +#define MTPROXYINSTALLER_H + +#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: + 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); + + static amnezia::ErrorCode queryDiagnostics(SshSession &sshSession, const amnezia::ServerCredentials &credentials, + amnezia::DockerContainer container, int listenPort, + MtProxyContainerDiagnostics &out); +}; + +#endif // MTPROXYINSTALLER_H diff --git a/client/core/installers/telemtInstaller.cpp b/client/core/installers/telemtInstaller.cpp new file mode 100644 index 000000000..ff0d595c7 --- /dev/null +++ b/client/core/installers/telemtInstaller.cpp @@ -0,0 +1,79 @@ +#include "telemtInstaller.h" + +#include "core/utils/containerEnum.h" +#include "core/utils/containers/containerUtils.h" +#include "core/utils/selfhosted/sshSession.h" +#include "core/models/containerConfig.h" +#include "core/models/protocols/telemtProtocolConfig.h" + +#include +#include +#include +#include + +#include + +using namespace amnezia; + +namespace { + constexpr QLatin1String kTelemtClientJsonPath("/data/amnezia-telemt-client.json"); + constexpr QLatin1String kTelemtClientJsonUploadPath("data/amnezia-telemt-client.json"); + constexpr QLatin1String kTelemtSecretPath("/data/secret"); +} + +TelemtInstaller::TelemtInstaller(QObject *parent) : InstallerBase(parent) {} + +ErrorCode TelemtInstaller::extractConfigFromContainer(DockerContainer container, const ServerCredentials &credentials, + SshSession *sshSession, ContainerConfig &config) { + if (container != DockerContainer::Telemt || !sshSession) { + return ErrorCode::NoError; + } + + TelemtProtocolConfig *tc = config.getTelemtProtocolConfig(); + if (!tc) { + return ErrorCode::NoError; + } + + ErrorCode jsonErr = ErrorCode::NoError; + const QByteArray jsonRaw = + sshSession->getTextFileFromContainer(container, credentials, QString(kTelemtClientJsonPath), 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 = tc->toJson(); + const QJsonObject snap = doc.object(); + for (auto it = snap.constBegin(); it != snap.constEnd(); ++it) { + merged.insert(it.key(), it.value()); + } + *tc = TelemtProtocolConfig::fromJson(merged); + } + } + + ErrorCode secretErr = ErrorCode::NoError; + const QByteArray secretRaw = + sshSession->getTextFileFromContainer(container, credentials, QString(kTelemtSecretPath), 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()) { + tc->secret = sec; + } + } + + return ErrorCode::NoError; +} + +void TelemtInstaller::uploadClientSettingsSnapshot(SshSession &sshSession, const ServerCredentials &credentials, + DockerContainer container, const ContainerConfig &config) { + const TelemtProtocolConfig *tc = config.getTelemtProtocolConfig(); + if (!tc) { + return; + } + const QByteArray payload = QJsonDocument(tc->toJson()).toJson(QJsonDocument::Compact); + const ErrorCode err = sshSession.uploadTextFileToContainer(container, credentials, QString::fromUtf8(payload), + QString(kTelemtClientJsonUploadPath)); + if (err != ErrorCode::NoError) { + qWarning() << "TelemtInstaller::uploadClientSettingsSnapshot failed" << err; + } +} diff --git a/client/core/installers/telemtInstaller.h b/client/core/installers/telemtInstaller.h new file mode 100644 index 000000000..13323e8a7 --- /dev/null +++ b/client/core/installers/telemtInstaller.h @@ -0,0 +1,20 @@ +#ifndef TELEMTINSTALLER_H +#define TELEMTINSTALLER_H + +#include "installerBase.h" + +class TelemtInstaller : public InstallerBase { +Q_OBJECT +public: + explicit TelemtInstaller(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 // TELEMTINSTALLER_H diff --git a/client/core/installers/xrayInstaller.cpp b/client/core/installers/xrayInstaller.cpp index 12a4b9833..30e61cc2a 100644 --- a/client/core/installers/xrayInstaller.cpp +++ b/client/core/installers/xrayInstaller.cpp @@ -14,8 +14,18 @@ #include "core/models/protocols/xrayProtocolConfig.h" #include "logger.h" -namespace { +namespace +{ Logger logger("XrayInstaller"); + + // Xray expects uTLS preset names (chrome, firefox, …). Old Amnezia/server templates used "Mozilla/5.0". + QString normalizeXrayFingerprint(const QString &fp) + { + if (fp.isEmpty() || fp.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + return QString::fromLatin1(protocols::xray::defaultFingerprint); + } + return fp; + } } using namespace amnezia; @@ -63,18 +73,251 @@ ErrorCode XrayInstaller::extractConfigFromContainer(DockerContainer container, c } QJsonObject streamSettings = inbound[protocols::xray::streamSettings].toObject(); - QJsonObject realitySettings = streamSettings[protocols::xray::realitySettings].toObject(); - if (!realitySettings.contains(protocols::xray::serverNames)) { - logger.error() << "Settings missing 'serverNames' field"; + auto *xrayConfig = config.getXrayProtocolConfig(); + if (!xrayConfig) { + logger.error() << "No XrayProtocolConfig in ContainerConfig"; return ErrorCode::InternalError; } - QString siteName = realitySettings[protocols::xray::serverNames][0].toString(); + XrayServerConfig &srv = xrayConfig->serverConfig; - if (auto* xrayConfig = config.getXrayProtocolConfig()) { - xrayConfig->serverConfig.site = siteName; + // ── Port ───────────────────────────────────────────────────────── + if (inbound.contains(protocols::xray::port)) { + srv.port = QString::number(inbound[protocols::xray::port].toInt()); } - + + // ── Network (transport) ─────────────────────────────────────────── + QString networkVal = streamSettings.value(protocols::xray::network).toString("tcp"); + if (networkVal == "xhttp") { + srv.transport = "xhttp"; + } else if (networkVal == "kcp") { + srv.transport = "mkcp"; + } else { + srv.transport = "raw"; + } + + // ── Security ────────────────────────────────────────────────────── + srv.security = streamSettings.value(protocols::xray::security).toString("reality"); + + // ── Reality settings ────────────────────────────────────────────── + if (srv.security == "reality") { + QJsonObject rs = streamSettings.value(protocols::xray::realitySettings).toObject(); + + // serverNames array → site + sni + if (rs.contains(protocols::xray::serverNames)) { + QString sniVal = rs[protocols::xray::serverNames].toArray().first().toString(); + srv.sni = sniVal; + srv.site = sniVal; + } else if (rs.contains(protocols::xray::serverName)) { + srv.sni = rs[protocols::xray::serverName].toString(); + srv.site = srv.sni; + } + + srv.fingerprint = normalizeXrayFingerprint(rs.value(protocols::xray::fingerprint).toString()); + } + + // ── TLS settings ────────────────────────────────────────────────── + if (srv.security == "tls") { + QJsonObject tls = streamSettings.value("tlsSettings").toObject(); + srv.sni = tls.value(protocols::xray::serverName).toString(); + srv.fingerprint = normalizeXrayFingerprint(tls.value(protocols::xray::fingerprint).toString()); + + QJsonArray alpnArr = tls.value("alpn").toArray(); + QStringList alpnList; + for (const QJsonValue &v : alpnArr) { + alpnList << v.toString(); + } + srv.alpn = alpnList.join(","); + } + + // ── Flow (from users array) ─────────────────────────────────────── + if (inbound.contains(protocols::xray::settings)) { + QJsonObject s = inbound[protocols::xray::settings].toObject(); + QJsonArray clientsArr = s.value(protocols::xray::clients).toArray(); + if (!clientsArr.isEmpty()) { + srv.flow = clientsArr[0].toObject().value(protocols::xray::flow).toString(); + } + } + + // ── XHTTP settings (Xray-core SplitHTTPConfig + legacy Amnezia keys) ── + if (srv.transport == "xhttp") { + QJsonObject xhttpObj = streamSettings.value("xhttpSettings").toObject(); + { + const QString m = xhttpObj.value("mode").toString(); + if (m.isEmpty() || m == QLatin1String("auto")) + srv.xhttp.mode = QStringLiteral("Auto"); + else if (m == QLatin1String("packet-up")) + srv.xhttp.mode = QStringLiteral("Packet-up"); + else if (m == QLatin1String("stream-up")) + srv.xhttp.mode = QStringLiteral("Stream-up"); + else if (m == QLatin1String("stream-one")) + srv.xhttp.mode = QStringLiteral("Stream-one"); + else + srv.xhttp.mode = m; + } + + srv.xhttp.host = xhttpObj.value("host").toString(); + srv.xhttp.path = xhttpObj.value("path").toString(); + + { + const QJsonObject hdrs = xhttpObj.value("headers").toObject(); + if (hdrs.contains(QLatin1String("Host")) || !hdrs.isEmpty()) + srv.xhttp.headersTemplate = QStringLiteral("HTTP"); + } + + if (xhttpObj.contains(QLatin1String("uplinkHTTPMethod"))) + srv.xhttp.uplinkMethod = xhttpObj.value("uplinkHTTPMethod").toString(); + else + srv.xhttp.uplinkMethod = xhttpObj.value("method").toString(); + + srv.xhttp.disableGrpc = xhttpObj.value("noGRPCHeader").toBool(true); + srv.xhttp.disableSse = xhttpObj.value("noSSEHeader").toBool(true); + + auto sessionSeqUi = [](const QString &core) -> QString { + if (core.isEmpty() || core == QLatin1String("path")) + return QStringLiteral("Path"); + if (core == QLatin1String("cookie")) + return QStringLiteral("Cookie"); + if (core == QLatin1String("header")) + return QStringLiteral("Header"); + if (core == QLatin1String("query")) + return QStringLiteral("Query"); + return core; + }; + QString sess = xhttpObj.value("sessionPlacement").toString(); + if (sess.isEmpty()) + sess = xhttpObj.value("scSessionPlacement").toString(); + srv.xhttp.sessionPlacement = sessionSeqUi(sess); + + QString seq = xhttpObj.value("seqPlacement").toString(); + if (seq.isEmpty()) + seq = xhttpObj.value("scSeqPlacement").toString(); + srv.xhttp.seqPlacement = sessionSeqUi(seq); + + auto uplinkDataUi = [](const QString &core) -> QString { + if (core.isEmpty() || core == QLatin1String("body")) + return QStringLiteral("Body"); + if (core == QLatin1String("auto")) + return QStringLiteral("Auto"); + if (core == QLatin1String("header")) + return QStringLiteral("Header"); + if (core == QLatin1String("cookie")) + return QStringLiteral("Cookie"); + return core; + }; + QString udata = xhttpObj.value("uplinkDataPlacement").toString(); + if (udata.isEmpty()) + udata = xhttpObj.value("scUplinkDataPlacement").toString(); + srv.xhttp.uplinkDataPlacement = uplinkDataUi(udata); + + srv.xhttp.sessionKey = xhttpObj.value("sessionKey").toString(); + srv.xhttp.seqKey = xhttpObj.value("seqKey").toString(); + srv.xhttp.uplinkDataKey = xhttpObj.value("uplinkDataKey").toString(); + + if (xhttpObj.contains(QLatin1String("uplinkChunkSize"))) { + QJsonObject uc = xhttpObj.value("uplinkChunkSize").toObject(); + if (!uc.isEmpty()) + srv.xhttp.uplinkChunkSize = QString::number(uc.value("from").toInt()); + } else if (xhttpObj.contains(QLatin1String("xhttpUplinkChunkSize"))) { + srv.xhttp.uplinkChunkSize = QString::number(xhttpObj.value("xhttpUplinkChunkSize").toInt()); + } + if (xhttpObj.contains(QLatin1String("scMaxBufferedPosts"))) { + srv.xhttp.scMaxBufferedPosts = QString::number(xhttpObj.value("scMaxBufferedPosts").toVariant().toLongLong()); + } + + auto readRange = [&](const char *key, QString &minOut, QString &maxOut) { + QJsonObject r = xhttpObj.value(QLatin1String(key)).toObject(); + if (!r.isEmpty()) { + minOut = QString::number(r.value("from").toInt()); + maxOut = QString::number(r.value("to").toInt()); + } + }; + readRange("scMaxEachPostBytes", srv.xhttp.scMaxEachPostBytesMin, srv.xhttp.scMaxEachPostBytesMax); + readRange("scMinPostsIntervalMs", srv.xhttp.scMinPostsIntervalMsMin, srv.xhttp.scMinPostsIntervalMsMax); + readRange("scStreamUpServerSecs", srv.xhttp.scStreamUpServerSecsMin, srv.xhttp.scStreamUpServerSecsMax); + + auto loadPaddingFromObject = [&](const QJsonObject &pad) { + if (pad.contains(QLatin1String("xPaddingObfsMode"))) + srv.xhttp.xPadding.obfsMode = pad.value("xPaddingObfsMode").toBool(true); + srv.xhttp.xPadding.key = pad.value("xPaddingKey").toString(); + srv.xhttp.xPadding.header = pad.value("xPaddingHeader").toString(); + srv.xhttp.xPadding.placement = pad.value("xPaddingPlacement").toString(); + srv.xhttp.xPadding.method = pad.value("xPaddingMethod").toString(); + QJsonObject bytesRange = pad.value("xPaddingBytes").toObject(); + if (!bytesRange.isEmpty()) { + srv.xhttp.xPadding.bytesMin = QString::number(bytesRange.value("from").toInt()); + srv.xhttp.xPadding.bytesMax = QString::number(bytesRange.value("to").toInt()); + } + QString pl = srv.xhttp.xPadding.placement.toLower(); + if (pl == QLatin1String("cookie")) + srv.xhttp.xPadding.placement = QStringLiteral("Cookie"); + else if (pl == QLatin1String("header")) + srv.xhttp.xPadding.placement = QStringLiteral("Header"); + else if (pl == QLatin1String("query")) + srv.xhttp.xPadding.placement = QStringLiteral("Query"); + else if (pl == QLatin1String("queryinheader")) + srv.xhttp.xPadding.placement = QStringLiteral("Query in header"); + QString met = srv.xhttp.xPadding.method.toLower(); + if (met == QLatin1String("repeat-x")) + srv.xhttp.xPadding.method = QStringLiteral("Repeat-x"); + else if (met == QLatin1String("tokenish")) + srv.xhttp.xPadding.method = QStringLiteral("Tokenish"); + }; + if (xhttpObj.contains(QLatin1String("xPaddingObfsMode")) || xhttpObj.contains(QLatin1String("xPaddingKey")) + || !xhttpObj.value("xPaddingBytes").toObject().isEmpty()) { + loadPaddingFromObject(xhttpObj); + } else if (xhttpObj.contains(QLatin1String("xPadding")) && xhttpObj.value("xPadding").isObject()) { + const QJsonObject nested = xhttpObj.value("xPadding").toObject(); + if (!nested.isEmpty()) { + loadPaddingFromObject(nested); + if (!nested.contains(QLatin1String("xPaddingObfsMode"))) + srv.xhttp.xPadding.obfsMode = true; + } + } + + if (xhttpObj.contains(QLatin1String("xmux"))) { + QJsonObject mux = xhttpObj.value("xmux").toObject(); + srv.xhttp.xmux.enabled = true; + + auto readMuxRange = [&](const char *key, QString &minOut, QString &maxOut) { + QJsonObject r = mux.value(QLatin1String(key)).toObject(); + if (!r.isEmpty()) { + minOut = QString::number(r.value("from").toInt()); + maxOut = QString::number(r.value("to").toInt()); + } + }; + readMuxRange("maxConcurrency", srv.xhttp.xmux.maxConcurrencyMin, srv.xhttp.xmux.maxConcurrencyMax); + readMuxRange("maxConnections", srv.xhttp.xmux.maxConnectionsMin, srv.xhttp.xmux.maxConnectionsMax); + readMuxRange("cMaxReuseTimes", srv.xhttp.xmux.cMaxReuseTimesMin, srv.xhttp.xmux.cMaxReuseTimesMax); + readMuxRange("hMaxRequestTimes", srv.xhttp.xmux.hMaxRequestTimesMin, srv.xhttp.xmux.hMaxRequestTimesMax); + readMuxRange("hMaxReusableSecs", srv.xhttp.xmux.hMaxReusableSecsMin, srv.xhttp.xmux.hMaxReusableSecsMax); + + if (mux.contains(QLatin1String("hKeepAlivePeriod"))) + srv.xhttp.xmux.hKeepAlivePeriod = QString::number(mux.value("hKeepAlivePeriod").toVariant().toLongLong()); + } + } + + // ── mKCP settings ───────────────────────────────────────────────── + if (srv.transport == "mkcp") { + QJsonObject kcp = streamSettings.value("kcpSettings").toObject(); + if (kcp.contains("tti")) { + srv.mkcp.tti = QString::number(kcp["tti"].toInt()); + } + if (kcp.contains("uplinkCapacity")) { + srv.mkcp.uplinkCapacity = QString::number(kcp["uplinkCapacity"].toInt()); + } + if (kcp.contains("downlinkCapacity")) { + srv.mkcp.downlinkCapacity = QString::number(kcp["downlinkCapacity"].toInt()); + } + if (kcp.contains("readBufferSize")) { + srv.mkcp.readBufferSize = QString::number(kcp["readBufferSize"].toInt()); + } + if (kcp.contains("writeBufferSize")) { + srv.mkcp.writeBufferSize = QString::number(kcp["writeBufferSize"].toInt()); + } + srv.mkcp.congestion = kcp.value("congestion").toBool(true); + } + return ErrorCode::NoError; } diff --git a/client/core/models/containerConfig.cpp b/client/core/models/containerConfig.cpp index 834f1b540..6a008a13d 100644 --- a/client/core/models/containerConfig.cpp +++ b/client/core/models/containerConfig.cpp @@ -113,6 +113,26 @@ const Socks5ProxyProtocolConfig* ContainerConfig::getSocks5ProxyProtocolConfig() return protocolConfig.as(); } +MtProxyProtocolConfig* ContainerConfig::getMtProxyProtocolConfig() +{ + return protocolConfig.as(); +} + +const MtProxyProtocolConfig* ContainerConfig::getMtProxyProtocolConfig() const +{ + return protocolConfig.as(); +} + +TelemtProtocolConfig* ContainerConfig::getTelemtProtocolConfig() +{ + return protocolConfig.as(); +} + +const TelemtProtocolConfig* ContainerConfig::getTelemtProtocolConfig() 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..b07ff6dff 100644 --- a/client/core/models/containerConfig.h +++ b/client/core/models/containerConfig.h @@ -57,6 +57,12 @@ struct ContainerConfig { Socks5ProxyProtocolConfig* getSocks5ProxyProtocolConfig(); const Socks5ProxyProtocolConfig* getSocks5ProxyProtocolConfig() const; + MtProxyProtocolConfig* getMtProxyProtocolConfig(); + const MtProxyProtocolConfig* getMtProxyProtocolConfig() const; + + TelemtProtocolConfig* getTelemtProtocolConfig(); + const TelemtProtocolConfig* getTelemtProtocolConfig() const; + Ikev2ProtocolConfig* getIkev2ProtocolConfig(); const Ikev2ProtocolConfig* getIkev2ProtocolConfig() const; diff --git a/client/core/models/protocolConfig.cpp b/client/core/models/protocolConfig.cpp index ed91f7beb..24e879f18 100644 --- a/client/core/models/protocolConfig.cpp +++ b/client/core/models/protocolConfig.cpp @@ -9,6 +9,8 @@ #include "core/utils/protocolEnum.h" #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/dnsProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" +#include "core/models/protocols/telemtProtocolConfig.h" namespace amnezia { @@ -38,6 +40,10 @@ 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; + } else if constexpr (std::is_same_v) { + return Proto::Telemt; } return Proto::Unknown; }, data); @@ -65,6 +71,10 @@ 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; + } else if constexpr (std::is_same_v) { + return arg.port.isEmpty() ? QString(protocols::telemt::defaultPort) : arg.port; } return QString(); }, data); @@ -88,6 +98,10 @@ 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"); + } else if constexpr (std::is_same_v) { + return QStringLiteral("tcp"); } return QString(); }, data); @@ -299,6 +313,10 @@ 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)}; + case Proto::Telemt: + return ProtocolConfig{TelemtProtocolConfig::fromJson(json)}; default: return ProtocolConfig{AwgProtocolConfig{}}; } diff --git a/client/core/models/protocolConfig.h b/client/core/models/protocolConfig.h index 325f52ab9..324530087 100644 --- a/client/core/models/protocolConfig.h +++ b/client/core/models/protocolConfig.h @@ -22,6 +22,8 @@ #include "core/models/protocols/ikev2ProtocolConfig.h" #include "core/models/protocols/torProtocolConfig.h" #include "core/models/protocols/dnsProtocolConfig.h" +#include "core/models/protocols/mtProxyProtocolConfig.h" +#include "core/models/protocols/telemtProtocolConfig.h" namespace amnezia { @@ -36,6 +38,8 @@ struct ProtocolConfig { XrayProtocolConfig, SftpProtocolConfig, Socks5ProxyProtocolConfig, + MtProxyProtocolConfig, + TelemtProtocolConfig, 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/models/protocols/telemtProtocolConfig.cpp b/client/core/models/protocols/telemtProtocolConfig.cpp new file mode 100644 index 000000000..5f55d0e10 --- /dev/null +++ b/client/core/models/protocols/telemtProtocolConfig.cpp @@ -0,0 +1,162 @@ +#include "telemtProtocolConfig.h" + +#include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" + +#include +#include + +using namespace amnezia; + +QJsonObject TelemtProtocolConfig::toJson() const +{ + QJsonObject obj; + if (!port.isEmpty()) { + obj[QString(configKey::port)] = port; + } + if (!secret.isEmpty()) { + obj[protocols::telemt::secretKey] = secret; + } + if (!tag.isEmpty()) { + obj[protocols::telemt::tagKey] = tag; + } + if (!tgLink.isEmpty()) { + obj[protocols::telemt::tgLinkKey] = tgLink; + } + if (!tmeLink.isEmpty()) { + obj[protocols::telemt::tmeLinkKey] = tmeLink; + } + obj[protocols::telemt::isEnabledKey] = isEnabled; + if (!publicHost.isEmpty()) { + obj[protocols::telemt::publicHostKey] = publicHost; + } + if (!transportMode.isEmpty()) { + obj[protocols::telemt::transportModeKey] = transportMode; + } + if (!tlsDomain.isEmpty()) { + obj[protocols::telemt::tlsDomainKey] = tlsDomain; + } + obj[protocols::telemt::maskEnabledKey] = maskEnabled; + obj[protocols::telemt::tlsEmulationKey] = tlsEmulation; + obj[protocols::telemt::useMiddleProxyKey] = useMiddleProxy; + if (!userName.isEmpty()) { + obj[protocols::telemt::userNameKey] = userName; + } + if (!additionalSecrets.isEmpty()) { + obj[protocols::telemt::additionalSecretsKey] = QJsonArray::fromStringList(additionalSecrets); + } + if (!workersMode.isEmpty()) { + obj[protocols::telemt::workersModeKey] = workersMode; + } + if (!workers.isEmpty()) { + obj[protocols::telemt::workersKey] = workers; + } + obj[protocols::telemt::natEnabledKey] = natEnabled; + if (!natInternalIp.isEmpty()) { + obj[protocols::telemt::natInternalIpKey] = natInternalIp; + } + if (!natExternalIp.isEmpty()) { + obj[protocols::telemt::natExternalIpKey] = natExternalIp; + } + return obj; +} + +TelemtProtocolConfig TelemtProtocolConfig::fromJson(const QJsonObject &json) +{ + TelemtProtocolConfig c; + c.port = json.value(QString(configKey::port)).toString(); + c.secret = json.value(protocols::telemt::secretKey).toString(); + c.tag = json.value(protocols::telemt::tagKey).toString(); + c.tgLink = json.value(protocols::telemt::tgLinkKey).toString(); + c.tmeLink = json.value(protocols::telemt::tmeLinkKey).toString(); + c.isEnabled = json.value(protocols::telemt::isEnabledKey).toBool(true); + c.publicHost = json.value(protocols::telemt::publicHostKey).toString(); + c.transportMode = json.value(protocols::telemt::transportModeKey).toString(); + c.tlsDomain = json.value(protocols::telemt::tlsDomainKey).toString(); + c.maskEnabled = json.value(protocols::telemt::maskEnabledKey).toBool(true); + c.tlsEmulation = json.value(protocols::telemt::tlsEmulationKey).toBool(false); + c.useMiddleProxy = json.value(protocols::telemt::useMiddleProxyKey).toBool(true); + c.userName = json.value(protocols::telemt::userNameKey).toString(); + for (const auto &v : json.value(protocols::telemt::additionalSecretsKey).toArray()) { + const QString s = v.toString(); + if (!s.isEmpty()) { + c.additionalSecrets.append(s); + } + } + c.workersMode = json.value(protocols::telemt::workersModeKey).toString(); + c.workers = json.value(protocols::telemt::workersKey).toString(); + c.natEnabled = json.value(protocols::telemt::natEnabledKey).toBool(false); + c.natInternalIp = json.value(protocols::telemt::natInternalIpKey).toString(); + c.natExternalIp = json.value(protocols::telemt::natExternalIpKey).toString(); + return c; +} + +bool TelemtProtocolConfig::equalsDockerDeploymentSettings(const TelemtProtocolConfig &other) const +{ + const auto normPort = [](const QString &p) { + return p.isEmpty() ? QString(protocols::telemt::defaultPort) : p; + }; + const auto normTransport = [](const QString &t) { + return t.isEmpty() ? QString(protocols::telemt::transportModeStandard) : t; + }; + const auto normWorkersMode = [](const QString &m) { + return m.isEmpty() ? QString(protocols::telemt::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 (maskEnabled != other.maskEnabled) { + return false; + } + if (tlsEmulation != other.tlsEmulation) { + return false; + } + if (useMiddleProxy != other.useMiddleProxy) { + return false; + } + if (userName != other.userName) { + 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; +} diff --git a/client/core/models/protocols/telemtProtocolConfig.h b/client/core/models/protocols/telemtProtocolConfig.h new file mode 100644 index 000000000..0a8830e60 --- /dev/null +++ b/client/core/models/protocols/telemtProtocolConfig.h @@ -0,0 +1,38 @@ +#ifndef TELEMTPROTOCOLCONFIG_H +#define TELEMTPROTOCOLCONFIG_H + +#include +#include +#include + +namespace amnezia { + +struct TelemtProtocolConfig { + QString port; + QString secret; + QString tag; + QString tgLink; + QString tmeLink; + bool isEnabled = true; + QString publicHost; + QString transportMode; + QString tlsDomain; + bool maskEnabled = true; + bool tlsEmulation = false; + bool useMiddleProxy = true; + QString userName; + QStringList additionalSecrets; + QString workersMode; + QString workers; + bool natEnabled = false; + QString natInternalIp; + QString natExternalIp; + + QJsonObject toJson() const; + static TelemtProtocolConfig fromJson(const QJsonObject &json); + bool equalsDockerDeploymentSettings(const TelemtProtocolConfig &other) const; +}; + +} // namespace amnezia + +#endif // TELEMTPROTOCOLCONFIG_H diff --git a/client/core/models/protocols/xrayProtocolConfig.cpp b/client/core/models/protocols/xrayProtocolConfig.cpp index bb4e61457..a6c0043bc 100644 --- a/client/core/models/protocols/xrayProtocolConfig.cpp +++ b/client/core/models/protocols/xrayProtocolConfig.cpp @@ -3,20 +3,173 @@ #include #include -#include "../../../core/utils/protocolEnum.h" -#include "../../../core/protocols/protocolUtils.h" -#include "../../../core/utils/constants/configKeys.h" -#include "../../../core/utils/constants/protocolConstants.h" +#include "core/utils/protocolEnum.h" +#include "core/protocols/protocolUtils.h" +#include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" using namespace amnezia; using namespace ProtocolUtils; + namespace amnezia { +QJsonObject XrayXPaddingConfig::toJson() const +{ + QJsonObject obj; + if (!bytesMin.isEmpty()) obj[configKey::xPaddingBytesMin] = bytesMin; + if (!bytesMax.isEmpty()) obj[configKey::xPaddingBytesMax] = bytesMax; + obj[configKey::xPaddingObfsMode] = obfsMode; + if (!key.isEmpty()) obj[configKey::xPaddingKey] = key; + if (!header.isEmpty()) obj[configKey::xPaddingHeader] = header; + if (!placement.isEmpty()) obj[configKey::xPaddingPlacement] = placement; + if (!method.isEmpty()) obj[configKey::xPaddingMethod] = method; + return obj; +} + +XrayXPaddingConfig XrayXPaddingConfig::fromJson(const QJsonObject &json) +{ + XrayXPaddingConfig c; + c.bytesMin = json.value(configKey::xPaddingBytesMin).toString(); + c.bytesMax = json.value(configKey::xPaddingBytesMax).toString(); + c.obfsMode = json.value(configKey::xPaddingObfsMode).toBool(true); + c.key = json.value(configKey::xPaddingKey).toString(protocols::xray::defaultSite); + c.header = json.value(configKey::xPaddingHeader).toString(); + c.placement = json.value(configKey::xPaddingPlacement).toString(protocols::xray::defaultXPaddingPlacement); + c.method = json.value(configKey::xPaddingMethod).toString(protocols::xray::defaultXPaddingMethod); + return c; +} + +QJsonObject XrayXmuxConfig::toJson() const +{ + QJsonObject obj; + obj[configKey::xmuxEnabled] = enabled; + if (!maxConcurrencyMin.isEmpty()) obj[configKey::xmuxMaxConcurrencyMin] = maxConcurrencyMin; + if (!maxConcurrencyMax.isEmpty()) obj[configKey::xmuxMaxConcurrencyMax] = maxConcurrencyMax; + if (!maxConnectionsMin.isEmpty()) obj[configKey::xmuxMaxConnectionsMin] = maxConnectionsMin; + if (!maxConnectionsMax.isEmpty()) obj[configKey::xmuxMaxConnectionsMax] = maxConnectionsMax; + if (!cMaxReuseTimesMin.isEmpty()) obj[configKey::xmuxCMaxReuseTimesMin] = cMaxReuseTimesMin; + if (!cMaxReuseTimesMax.isEmpty()) obj[configKey::xmuxCMaxReuseTimesMax] = cMaxReuseTimesMax; + if (!hMaxRequestTimesMin.isEmpty()) obj[configKey::xmuxHMaxRequestTimesMin] = hMaxRequestTimesMin; + if (!hMaxRequestTimesMax.isEmpty()) obj[configKey::xmuxHMaxRequestTimesMax] = hMaxRequestTimesMax; + if (!hMaxReusableSecsMin.isEmpty()) obj[configKey::xmuxHMaxReusableSecsMin] = hMaxReusableSecsMin; + if (!hMaxReusableSecsMax.isEmpty()) obj[configKey::xmuxHMaxReusableSecsMax] = hMaxReusableSecsMax; + if (!hKeepAlivePeriod.isEmpty()) obj[configKey::xmuxHKeepAlivePeriod] = hKeepAlivePeriod; + return obj; +} + +XrayXmuxConfig XrayXmuxConfig::fromJson(const QJsonObject &json) +{ + XrayXmuxConfig c; + c.enabled = json.value(configKey::xmuxEnabled).toBool(true); + c.maxConcurrencyMin = json.value(configKey::xmuxMaxConcurrencyMin).toString("0"); + c.maxConcurrencyMax = json.value(configKey::xmuxMaxConcurrencyMax).toString("0"); + c.maxConnectionsMin = json.value(configKey::xmuxMaxConnectionsMin).toString("0"); + c.maxConnectionsMax = json.value(configKey::xmuxMaxConnectionsMax).toString("0"); + c.cMaxReuseTimesMin = json.value(configKey::xmuxCMaxReuseTimesMin).toString("0"); + c.cMaxReuseTimesMax = json.value(configKey::xmuxCMaxReuseTimesMax).toString("0"); + c.hMaxRequestTimesMin = json.value(configKey::xmuxHMaxRequestTimesMin).toString("0"); + c.hMaxRequestTimesMax = json.value(configKey::xmuxHMaxRequestTimesMax).toString("0"); + c.hMaxReusableSecsMin = json.value(configKey::xmuxHMaxReusableSecsMin).toString("0"); + c.hMaxReusableSecsMax = json.value(configKey::xmuxHMaxReusableSecsMax).toString("0"); + c.hKeepAlivePeriod = json.value(configKey::xmuxHKeepAlivePeriod).toString(); + return c; +} + +QJsonObject XrayXhttpConfig::toJson() const +{ + QJsonObject obj; + if (!mode.isEmpty()) obj[configKey::xhttpMode] = mode; + if (!host.isEmpty()) obj[configKey::xhttpHost] = host; + if (!path.isEmpty()) obj[configKey::xhttpPath] = path; + if (!headersTemplate.isEmpty()) obj[configKey::xhttpHeadersTemplate] = headersTemplate; + if (!uplinkMethod.isEmpty()) obj[configKey::xhttpUplinkMethod] = uplinkMethod; + obj[configKey::xhttpDisableGrpc] = disableGrpc; + obj[configKey::xhttpDisableSse] = disableSse; + + if (!sessionPlacement.isEmpty()) obj[configKey::xhttpSessionPlacement] = sessionPlacement; + if (!sessionKey.isEmpty()) obj[configKey::xhttpSessionKey] = sessionKey; + if (!seqPlacement.isEmpty()) obj[configKey::xhttpSeqPlacement] = seqPlacement; + if (!seqKey.isEmpty()) obj[configKey::xhttpSeqKey] = seqKey; + if (!uplinkDataPlacement.isEmpty()) obj[configKey::xhttpUplinkDataPlacement] = uplinkDataPlacement; + if (!uplinkDataKey.isEmpty()) obj[configKey::xhttpUplinkDataKey] = uplinkDataKey; + + if (!uplinkChunkSize.isEmpty()) obj[configKey::xhttpUplinkChunkSize] = uplinkChunkSize; + if (!scMaxBufferedPosts.isEmpty()) obj[configKey::xhttpScMaxBufferedPosts] = scMaxBufferedPosts; + if (!scMaxEachPostBytesMin.isEmpty()) obj[configKey::xhttpScMaxEachPostBytesMin] = scMaxEachPostBytesMin; + if (!scMaxEachPostBytesMax.isEmpty()) obj[configKey::xhttpScMaxEachPostBytesMax] = scMaxEachPostBytesMax; + if (!scMinPostsIntervalMsMin.isEmpty()) obj[configKey::xhttpScMinPostsIntervalMsMin] = scMinPostsIntervalMsMin; + if (!scMinPostsIntervalMsMax.isEmpty()) obj[configKey::xhttpScMinPostsIntervalMsMax] = scMinPostsIntervalMsMax; + if (!scStreamUpServerSecsMin.isEmpty()) obj[configKey::xhttpScStreamUpServerSecsMin] = scStreamUpServerSecsMin; + if (!scStreamUpServerSecsMax.isEmpty()) obj[configKey::xhttpScStreamUpServerSecsMax] = scStreamUpServerSecsMax; + + obj["xPadding"] = xPadding.toJson(); + obj["xmux"] = xmux.toJson(); + + return obj; +} + +XrayXhttpConfig XrayXhttpConfig::fromJson(const QJsonObject &json) +{ + XrayXhttpConfig c; + c.mode = json.value(configKey::xhttpMode).toString(protocols::xray::defaultXhttpMode); + c.host = json.value(configKey::xhttpHost).toString(protocols::xray::defaultSite); + c.path = json.value(configKey::xhttpPath).toString(); + c.headersTemplate = json.value(configKey::xhttpHeadersTemplate).toString(protocols::xray::defaultXhttpHeadersTemplate); + c.uplinkMethod = json.value(configKey::xhttpUplinkMethod).toString(protocols::xray::defaultXhttpUplinkMethod); + c.disableGrpc = json.value(configKey::xhttpDisableGrpc).toBool(true); + c.disableSse = json.value(configKey::xhttpDisableSse).toBool(true); + + c.sessionPlacement = json.value(configKey::xhttpSessionPlacement).toString(protocols::xray::defaultXhttpSessionPlacement); + c.sessionKey = json.value(configKey::xhttpSessionKey).toString(); + c.seqPlacement = json.value(configKey::xhttpSeqPlacement).toString(protocols::xray::defaultXhttpSessionPlacement); + c.seqKey = json.value(configKey::xhttpSeqKey).toString(); + c.uplinkDataPlacement = json.value(configKey::xhttpUplinkDataPlacement).toString(protocols::xray::defaultXhttpUplinkDataPlacement); + c.uplinkDataKey = json.value(configKey::xhttpUplinkDataKey).toString(); + + c.uplinkChunkSize = json.value(configKey::xhttpUplinkChunkSize).toString("0"); + c.scMaxBufferedPosts = json.value(configKey::xhttpScMaxBufferedPosts).toString(); + c.scMaxEachPostBytesMin = json.value(configKey::xhttpScMaxEachPostBytesMin).toString("1"); + c.scMaxEachPostBytesMax = json.value(configKey::xhttpScMaxEachPostBytesMax).toString("100"); + c.scMinPostsIntervalMsMin = json.value(configKey::xhttpScMinPostsIntervalMsMin).toString("100"); + c.scMinPostsIntervalMsMax = json.value(configKey::xhttpScMinPostsIntervalMsMax).toString("800"); + c.scStreamUpServerSecsMin = json.value(configKey::xhttpScStreamUpServerSecsMin).toString("1"); + c.scStreamUpServerSecsMax = json.value(configKey::xhttpScStreamUpServerSecsMax).toString("100"); + + c.xPadding = XrayXPaddingConfig::fromJson(json.value("xPadding").toObject()); + c.xmux = XrayXmuxConfig::fromJson(json.value("xmux").toObject()); + + return c; +} + +QJsonObject XrayMkcpConfig::toJson() const +{ + QJsonObject obj; + if (!tti.isEmpty()) obj[configKey::mkcpTti] = tti; + if (!uplinkCapacity.isEmpty()) obj[configKey::mkcpUplinkCapacity] = uplinkCapacity; + if (!downlinkCapacity.isEmpty()) obj[configKey::mkcpDownlinkCapacity] = downlinkCapacity; + if (!readBufferSize.isEmpty()) obj[configKey::mkcpReadBufferSize] = readBufferSize; + if (!writeBufferSize.isEmpty()) obj[configKey::mkcpWriteBufferSize] = writeBufferSize; + obj[configKey::mkcpCongestion] = congestion; + return obj; +} + +XrayMkcpConfig XrayMkcpConfig::fromJson(const QJsonObject &json) +{ + XrayMkcpConfig c; + c.tti = json.value(configKey::mkcpTti).toString(); + c.uplinkCapacity = json.value(configKey::mkcpUplinkCapacity).toString(); + c.downlinkCapacity = json.value(configKey::mkcpDownlinkCapacity).toString(); + c.readBufferSize = json.value(configKey::mkcpReadBufferSize).toString(); + c.writeBufferSize = json.value(configKey::mkcpWriteBufferSize).toString(); + c.congestion = json.value(configKey::mkcpCongestion).toBool(true); + return c; +} QJsonObject XrayServerConfig::toJson() const { QJsonObject obj; - + + // Existing fields if (!port.isEmpty()) { obj[configKey::port] = port; } @@ -29,60 +182,96 @@ QJsonObject XrayServerConfig::toJson() const if (!site.isEmpty()) { obj[configKey::site] = site; } - + if (isThirdPartyConfig) { obj[configKey::isThirdPartyConfig] = isThirdPartyConfig; } - + + // New: Security + if (!security.isEmpty()) { + obj[configKey::xraySecurity] = security; + } + if (!flow.isEmpty()) { + obj[configKey::xrayFlow] = flow; + } + if (!fingerprint.isEmpty()) { + obj[configKey::xrayFingerprint] = fingerprint; + } + if (!sni.isEmpty()) { + obj[configKey::xraySni] = sni; + } + if (!alpn.isEmpty()) { + obj[configKey::xrayAlpn] = alpn; + } + + // New: Transport + if (!transport.isEmpty()) { + obj[configKey::xrayTransport] = transport; + } + obj["xhttp"] = xhttp.toJson(); + obj["mkcp"] = mkcp.toJson(); + return obj; } -XrayServerConfig XrayServerConfig::fromJson(const QJsonObject& json) +XrayServerConfig XrayServerConfig::fromJson(const QJsonObject &json) { - XrayServerConfig config; - - config.port = json.value(configKey::port).toString(); - config.transportProto = json.value(configKey::transportProto).toString(); - config.subnetAddress = json.value(configKey::subnetAddress).toString(); - config.site = json.value(configKey::site).toString(); - - config.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false); - - return config; + XrayServerConfig c; + + // Existing fields + c.port = json.value(configKey::port).toString(); + c.transportProto = json.value(configKey::transportProto).toString(); + c.subnetAddress = json.value(configKey::subnetAddress).toString(); + c.site = json.value(configKey::site).toString(); + c.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false); + + // New: Security + c.security = json.value(configKey::xraySecurity).toString(protocols::xray::defaultSecurity); + c.flow = json.value(configKey::xrayFlow).toString(protocols::xray::defaultFlow); + c.fingerprint = json.value(configKey::xrayFingerprint).toString(protocols::xray::defaultFingerprint); + if (c.fingerprint.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + c.fingerprint = QString::fromLatin1(protocols::xray::defaultFingerprint); + } + c.sni = json.value(configKey::xraySni).toString(protocols::xray::defaultSni); + c.alpn = json.value(configKey::xrayAlpn).toString(protocols::xray::defaultAlpn); + + // New: Transport + c.transport = json.value(configKey::xrayTransport).toString(protocols::xray::defaultTransport); + c.xhttp = XrayXhttpConfig::fromJson(json.value("xhttp").toObject()); + c.mkcp = XrayMkcpConfig::fromJson(json.value("mkcp").toObject()); + + return c; } -bool XrayServerConfig::hasEqualServerSettings(const XrayServerConfig& other) const +bool XrayServerConfig::hasEqualServerSettings(const XrayServerConfig &other) const { - return port == other.port && site == other.site; + return port == other.port + && site == other.site + && security == other.security + && flow == other.flow + && transport == other.transport + && fingerprint == other.fingerprint + && sni == other.sni; } QJsonObject XrayClientConfig::toJson() const { QJsonObject obj; - - if (!nativeConfig.isEmpty()) { - obj[configKey::config] = nativeConfig; - } - if (!localPort.isEmpty()) { - obj[configKey::localPort] = localPort; - } - if (!id.isEmpty()) { - obj[configKey::clientId] = id; - } - + if (!nativeConfig.isEmpty()) obj[configKey::config] = nativeConfig; + if (!localPort.isEmpty()) obj[configKey::localPort] = localPort; + if (!id.isEmpty()) obj[configKey::clientId] = id; return obj; } -XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) +XrayClientConfig XrayClientConfig::fromJson(const QJsonObject &json) { - XrayClientConfig config; - - config.nativeConfig = json.value(configKey::config).toString(); - config.localPort = json.value(configKey::localPort).toString(); - config.id = json.value(configKey::clientId).toString(); - - if (config.id.isEmpty() && !config.nativeConfig.isEmpty()) { - QJsonDocument doc = QJsonDocument::fromJson(config.nativeConfig.toUtf8()); + XrayClientConfig c; + c.nativeConfig = json.value(configKey::config).toString(); + c.localPort = json.value(configKey::localPort).toString(); + c.id = json.value(configKey::clientId).toString(); + + if (c.id.isEmpty() && !c.nativeConfig.isEmpty()) { + QJsonDocument doc = QJsonDocument::fromJson(c.nativeConfig.toUtf8()); if (!doc.isNull() && doc.isObject()) { QJsonObject configObj = doc.object(); if (configObj.contains(protocols::xray::outbounds)) { @@ -100,7 +289,7 @@ XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) if (!users.isEmpty()) { QJsonObject user = users[0].toObject(); if (user.contains(protocols::xray::id)) { - config.id = user[protocols::xray::id].toString(); + c.id = user[protocols::xray::id].toString(); } } } @@ -111,16 +300,15 @@ XrayClientConfig XrayClientConfig::fromJson(const QJsonObject& json) } } } - - return config; + + return c; } QJsonObject XrayProtocolConfig::toJson() const { QJsonObject obj = serverConfig.toJson(); - + if (clientConfig.has_value()) { - // Third-party import: nativeConfig is raw Xray JSON (inbounds/outbounds) QJsonDocument doc = QJsonDocument::fromJson(clientConfig->nativeConfig.toUtf8()); if (!doc.isNull() && doc.isObject() && doc.object().contains(protocols::xray::outbounds) && !doc.object().contains(configKey::config)) { @@ -130,22 +318,20 @@ QJsonObject XrayProtocolConfig::toJson() const obj[configKey::lastConfig] = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact)); } } - + return obj; } -XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject& json) +XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject &json) { - XrayProtocolConfig config; - - config.serverConfig = XrayServerConfig::fromJson(json); - + XrayProtocolConfig c; + c.serverConfig = XrayServerConfig::fromJson(json); + QString lastConfigStr = json.value(configKey::lastConfig).toString(); if (!lastConfigStr.isEmpty()) { QJsonDocument doc = QJsonDocument::fromJson(lastConfigStr.toUtf8()); if (doc.isObject()) { QJsonObject parsed = doc.object(); - // Third-party import stores raw Xray config (inbounds/outbounds) directly if (parsed.contains(protocols::xray::outbounds) && !parsed.contains(configKey::config)) { XrayClientConfig clientCfg; clientCfg.nativeConfig = lastConfigStr; @@ -158,14 +344,14 @@ XrayProtocolConfig XrayProtocolConfig::fromJson(const QJsonObject& json) } } } - config.clientConfig = clientCfg; + c.clientConfig = clientCfg; } else { - config.clientConfig = XrayClientConfig::fromJson(parsed); + c.clientConfig = XrayClientConfig::fromJson(parsed); } } } - - return config; + + return c; } bool XrayProtocolConfig::hasClientConfig() const @@ -173,7 +359,7 @@ bool XrayProtocolConfig::hasClientConfig() const return clientConfig.has_value(); } -void XrayProtocolConfig::setClientConfig(const XrayClientConfig& config) +void XrayProtocolConfig::setClientConfig(const XrayClientConfig &config) { clientConfig = config; } @@ -184,4 +370,3 @@ void XrayProtocolConfig::clearClientConfig() } } // namespace amnezia - diff --git a/client/core/models/protocols/xrayProtocolConfig.h b/client/core/models/protocols/xrayProtocolConfig.h index fc52a81b3..eaf9abfd2 100644 --- a/client/core/models/protocols/xrayProtocolConfig.h +++ b/client/core/models/protocols/xrayProtocolConfig.h @@ -2,47 +2,145 @@ #define XRAYPROTOCOLCONFIG_H #include +#include "core/utils/constants/protocolConstants.h" #include #include namespace amnezia { +// ── xPadding ───────────────────────────────────────────────────────────────── +struct XrayXPaddingConfig { + QString bytesMin; // xPaddingBytes min + QString bytesMax; // xPaddingBytes max + bool obfsMode = true; // xPaddingObfsMode + QString key; // xPaddingKey + QString header; // xPaddingHeader + QString placement = protocols::xray::defaultXPaddingPlacement; // xPaddingPlacement: Cookie|Header|Query|Body + QString method = protocols::xray::defaultXPaddingMethod; // xPaddingMethod: Repeat-x|Random|Zero + + QJsonObject toJson() const; + static XrayXPaddingConfig fromJson(const QJsonObject &json); +}; + +// ── xmux ───────────────────────────────────────────────────────────────────── +struct XrayXmuxConfig { + bool enabled = true; + + QString maxConcurrencyMin = "0"; + QString maxConcurrencyMax = "0"; + QString maxConnectionsMin = "0"; + QString maxConnectionsMax = "0"; + QString cMaxReuseTimesMin = "0"; + QString cMaxReuseTimesMax = "0"; + QString hMaxRequestTimesMin = "0"; + QString hMaxRequestTimesMax = "0"; + QString hMaxReusableSecsMin = "0"; + QString hMaxReusableSecsMax = "0"; + QString hKeepAlivePeriod; + + QJsonObject toJson() const; + static XrayXmuxConfig fromJson(const QJsonObject &json); +}; + +// ── XHTTP transport ─────────────────────────────────────────────────────────── +struct XrayXhttpConfig { + QString mode = protocols::xray::defaultXhttpMode; // Auto|Packet-up|Stream-up|Stream-one + QString host = protocols::xray::defaultXhttpHost; + QString path; + QString headersTemplate = protocols::xray::defaultXhttpHeadersTemplate; // HTTP|None + QString uplinkMethod = protocols::xray::defaultXhttpUplinkMethod; // POST|PUT|PATCH + bool disableGrpc = true; + bool disableSse = true; + + // Session & Sequence + QString sessionPlacement = protocols::xray::defaultXhttpSessionPlacement; + QString sessionKey = protocols::xray::defaultXhttpSessionKey; + QString seqPlacement = protocols::xray::defaultXhttpSeqPlacement; + QString seqKey; + QString uplinkDataPlacement = protocols::xray::defaultXhttpUplinkDataPlacement; + QString uplinkDataKey; + + // Traffic Shaping + QString uplinkChunkSize = protocols::xray::defaultXhttpUplinkChunkSize; + QString scMaxBufferedPosts; + QString scMaxEachPostBytesMin = protocols::xray::defaultXhttpScMaxEachPostBytesMin; + QString scMaxEachPostBytesMax = protocols::xray::defaultXhttpScMaxEachPostBytesMax; + QString scMinPostsIntervalMsMin = protocols::xray::defaultXhttpScMinPostsIntervalMsMin; + QString scMinPostsIntervalMsMax = protocols::xray::defaultXhttpScMinPostsIntervalMsMax; + QString scStreamUpServerSecsMin = protocols::xray::defaultXhttpScStreamUpServerSecsMin; + QString scStreamUpServerSecsMax = protocols::xray::defaultXhttpScStreamUpServerSecsMax; + + XrayXPaddingConfig xPadding; + XrayXmuxConfig xmux; + + QJsonObject toJson() const; + static XrayXhttpConfig fromJson(const QJsonObject &json); +}; + +// ── mKCP transport ──────────────────────────────────────────────────────────── +struct XrayMkcpConfig { + QString tti; + QString uplinkCapacity; + QString downlinkCapacity; + QString readBufferSize; + QString writeBufferSize; + bool congestion = true; + + QJsonObject toJson() const; + static XrayMkcpConfig fromJson(const QJsonObject &json); +}; + +// ── Server config (settings editable by user) ───────────────────────────────── struct XrayServerConfig { QString port; QString transportProto; QString subnetAddress; QString site; bool isThirdPartyConfig = false; - + + // New: Security + QString security = protocols::xray::defaultSecurity; + QString flow = protocols::xray::defaultFlow; + QString fingerprint = protocols::xray::defaultFingerprint; + QString sni = protocols::xray::defaultSni; + QString alpn = protocols::xray::defaultAlpn; + + // New: Transport + QString transport = protocols::xray::defaultTransport; + XrayXhttpConfig xhttp; + XrayMkcpConfig mkcp; + QJsonObject toJson() const; - static XrayServerConfig fromJson(const QJsonObject& json); - - bool hasEqualServerSettings(const XrayServerConfig& other) const; + + static XrayServerConfig fromJson(const QJsonObject &json); + + bool hasEqualServerSettings(const XrayServerConfig &other) const; }; +// ── Client config (generated, not edited by user) ───────────────────────────── struct XrayClientConfig { QString nativeConfig; QString localPort; QString id; - + QJsonObject toJson() const; - static XrayClientConfig fromJson(const QJsonObject& json); + static XrayClientConfig fromJson(const QJsonObject &json); }; +// ── Top-level protocol config ────────────────────────────────────────────────── struct XrayProtocolConfig { XrayServerConfig serverConfig; std::optional clientConfig; - + QJsonObject toJson() const; - static XrayProtocolConfig fromJson(const QJsonObject& json); - + static XrayProtocolConfig fromJson(const QJsonObject &json); + bool hasClientConfig() const; - void setClientConfig(const XrayClientConfig& config); + void setClientConfig(const XrayClientConfig &config); void clearClientConfig(); }; } // namespace amnezia #endif // XRAYPROTOCOLCONFIG_H - diff --git a/client/core/protocols/protocolUtils.cpp b/client/core/protocols/protocolUtils.cpp index 65446e0ec..fe8a1454b 100644 --- a/client/core/protocols/protocolUtils.cpp +++ b/client/core/protocols/protocolUtils.cpp @@ -68,7 +68,10 @@ 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)") }, + { Proto::Telemt, QObject::tr("Telemt (Telegram)") }, + }; } QMap ProtocolUtils::protocolDescriptions() @@ -92,6 +95,8 @@ 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; + case Proto::Telemt: return ServiceType::Other; default: return ServiceType::Other; } } @@ -104,6 +109,8 @@ int ProtocolUtils::getPortForInstall(Proto p) case OpenVpn: case Socks5Proxy: return QRandomGenerator::global()->bounded(30000, 50000); + case MtProxy: + case Telemt: default: return defaultPort(p); } @@ -123,6 +130,8 @@ 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(); + case Proto::Telemt: return QString(protocols::telemt::defaultPort).toInt(); default: return -1; } } @@ -141,6 +150,8 @@ 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; + case Proto::Telemt: return true; default: return false; } } @@ -161,6 +172,8 @@ 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; + case Proto::Telemt: return TransportProto::Tcp; default: return TransportProto::Udp; } } @@ -180,9 +193,10 @@ 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; + case Proto::Telemt: return false; default: return false; } - return false; } QString ProtocolUtils::key_proto_config_data(Proto p) @@ -208,4 +222,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/protocols/xrayProtocol.cpp b/client/core/protocols/xrayProtocol.cpp old mode 100755 new mode 100644 index cceaddc28..9b9b6e41e --- a/client/core/protocols/xrayProtocol.cpp +++ b/client/core/protocols/xrayProtocol.cpp @@ -2,6 +2,7 @@ #include "core/protocols/protocolUtils.h" #include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" #include "core/utils/ipcClient.h" #include "core/utils/networkUtilities.h" #include "core/utils/serialization/serialization.h" @@ -9,6 +10,7 @@ #include #include +#include #include #include #include @@ -79,12 +81,29 @@ ErrorCode XrayProtocol::start() m_socksPassword = creds.password; m_socksPort = creds.port; - const QString xrayConfigStr = QJsonDocument(m_xrayConfig).toJson(QJsonDocument::Compact); + QString xrayConfigStr = QJsonDocument(m_xrayConfig).toJson(QJsonDocument::Compact); if (xrayConfigStr.isEmpty()) { qCritical() << "Xray config is empty"; return ErrorCode::XrayExecutableCrashed; } + // Fix fingerprint: old configs may contain "Mozilla/5.0" which xray-core rejects. + // Replace with the correct default at runtime so stale stored configs still work. + if (xrayConfigStr.contains("Mozilla/5.0", Qt::CaseInsensitive)) { + xrayConfigStr.replace("Mozilla/5.0", amnezia::protocols::xray::defaultFingerprint, + Qt::CaseInsensitive); + qDebug() << "XrayProtocol: patched legacy fingerprint to" + << amnezia::protocols::xray::defaultFingerprint; + } + + // Fix inbound listen address: old configs may use "10.33.0.2" which doesn't exist + // until TUN is created. xray must listen on 127.0.0.1 so tun2socks can connect. + if (xrayConfigStr.contains(amnezia::protocols::xray::defaultLocalAddr)) { + xrayConfigStr.replace(amnezia::protocols::xray::defaultLocalAddr, + amnezia::protocols::xray::defaultLocalListenAddr); + qDebug() << "XrayProtocol: patched legacy inbound listen address to 127.0.0.1"; + } + return IpcClient::withInterface( [&](QSharedPointer iface) { auto xrayStart = iface->xrayStart(xrayConfigStr); @@ -188,6 +207,33 @@ ErrorCode XrayProtocol::startTun2Socks() connect( m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::finished, this, [this](int exitCode, QProcess::ExitStatus exitStatus) { + // Check stdout for "resource busy" — the TUN device was not yet released + // by the previous tun2socks instance. Retry after a short delay. + bool resourceBusy = false; + if (m_tun2socksProcess) { + auto readOut = m_tun2socksProcess->readAllStandardOutput(); + if (readOut.waitForFinished()) { + resourceBusy = readOut.returnValue().contains("resource busy"); + } + } + + if (resourceBusy && m_tun2socksRetryCount < maxTun2SocksRetries) { + m_tun2socksRetryCount++; + qWarning() << QString("Tun2socks: TUN resource busy, retrying (%1/%2) in %3ms...") + .arg(m_tun2socksRetryCount) + .arg(maxTun2SocksRetries) + .arg(tun2socksRetryDelayMs); + QTimer::singleShot(tun2socksRetryDelayMs, this, [this]() { + if (ErrorCode err = startTun2Socks(); err != ErrorCode::NoError) { + stop(); + setLastError(err); + } + }); + return; + } + + m_tun2socksRetryCount = 0; + if (exitStatus == QProcess::ExitStatus::CrashExit) { qCritical() << "Tun2socks process crashed!"; } else { diff --git a/client/core/protocols/xrayProtocol.h b/client/core/protocols/xrayProtocol.h index e831ab2f4..55b6d1d5c 100644 --- a/client/core/protocols/xrayProtocol.h +++ b/client/core/protocols/xrayProtocol.h @@ -35,6 +35,9 @@ private: int m_socksPort = 10808; QSharedPointer m_tun2socksProcess; + int m_tun2socksRetryCount = 0; + static constexpr int maxTun2SocksRetries = 5; + static constexpr int tun2socksRetryDelayMs = 400; }; #endif // XRAYPROTOCOL_H diff --git a/client/core/repositories/secureAppSettingsRepository.cpp b/client/core/repositories/secureAppSettingsRepository.cpp index 3d3699ef7..1ce7f0c58 100644 --- a/client/core/repositories/secureAppSettingsRepository.cpp +++ b/client/core/repositories/secureAppSettingsRepository.cpp @@ -478,4 +478,12 @@ void SecureAppSettingsRepository::setInstallationUuid(const QString &uuid) m_settings->setValue("Conf/installationUuid", uuid); } +QByteArray SecureAppSettingsRepository::xraySavedConfigs() const +{ + return value("Xray/savedConfigs").toByteArray(); +} +void SecureAppSettingsRepository::setXraySavedConfigs(const QByteArray &data) +{ + setValue("Xray/savedConfigs", data); +} diff --git a/client/core/repositories/secureAppSettingsRepository.h b/client/core/repositories/secureAppSettingsRepository.h index 7b08a618c..2b2534799 100644 --- a/client/core/repositories/secureAppSettingsRepository.h +++ b/client/core/repositories/secureAppSettingsRepository.h @@ -99,6 +99,9 @@ public: QString nextAvailableServerName() const; + QByteArray xraySavedConfigs() const; + void setXraySavedConfigs(const QByteArray &data); + signals: void appLanguageChanged(QLocale locale); void allowedDnsServersChanged(const QStringList &servers); diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index 4555d80dc..6856652d5 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -2,7 +2,6 @@ #include "core/utils/serverConfigUtils.h" #include "core/utils/constants/configKeys.h" -#include #include #include #include @@ -104,10 +103,6 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl return amnezia::ErrorCode::ApiUpdateRequestError; } - qDebug() << QString::fromUtf8(responseBody); - qDebug() << replyError; - qDebug() << httpStatusCode; - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); @@ -233,18 +228,3 @@ QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject) return vpnKeyText; } - -QString apiUtils::countryCodeBaseForFlag(const QString &fullCountryCode) -{ - const QString trimmed = fullCountryCode.trimmed(); - if (trimmed.isEmpty()) { - return QString(); - } - const int dashIdx = trimmed.indexOf(QLatin1Char('-')); - const QString base = dashIdx < 0 ? trimmed : trimmed.left(dashIdx); - const QString normalized = base.trimmed(); - if (normalized.isEmpty()) { - return QString(); - } - return normalized.toUpper(); -} diff --git a/client/core/utils/api/apiUtils.h b/client/core/utils/api/apiUtils.h index c601ec895..e1ada61ae 100644 --- a/client/core/utils/api/apiUtils.h +++ b/client/core/utils/api/apiUtils.h @@ -25,9 +25,6 @@ namespace apiUtils QString getPremiumV1VpnKey(const QJsonObject &serverConfigObject); QString getPremiumV2VpnKey(const QJsonObject &serverConfigObject); - - // ISO2-style segment for flagKit assets (e.g. US-WEST -> US). Do not use in API request bodies. - QString countryCodeBaseForFlag(const QString &fullCountryCode); } #endif // APIUTILS_H diff --git a/client/core/utils/constants/configKeys.h b/client/core/utils/constants/configKeys.h index 3272eb2bb..b896cdc37 100644 --- a/client/core/utils/constants/configKeys.h +++ b/client/core/utils/constants/configKeys.h @@ -93,6 +93,8 @@ namespace amnezia constexpr QLatin1String xray("xray"); constexpr QLatin1String ssxray("ssxray"); constexpr QLatin1String socks5proxy("socks5proxy"); + constexpr QLatin1String mtproxy("mtproxy"); + constexpr QLatin1String telemt("telemt"); constexpr QLatin1String splitTunnelSites("splitTunnelSites"); constexpr QLatin1String splitTunnelType("splitTunnelType"); @@ -124,6 +126,76 @@ namespace amnezia constexpr QLatin1String dataSent("dataSent"); constexpr QLatin1String storageServerId("storageServerId"); + + // ── Xray-specific keys ──────────────────────────────────────── + + // Security + constexpr QLatin1String xraySecurity("xray_security"); // none | tls | reality + constexpr QLatin1String xrayFlow("xray_flow"); // "" | xtls-rprx-vision | xtls-rprx-vision-udp443 + constexpr QLatin1String xrayFingerprint("xray_fingerprint"); // Mozilla/5.0 | chrome | firefox | ... + constexpr QLatin1String xraySni("xray_sni"); // Server Name (SNI) + constexpr QLatin1String xrayAlpn("xray_alpn"); // HTTP/2 | HTTP/1.1 | HTTP/2,HTTP/1.1 + + // Transport — common + constexpr QLatin1String xrayTransport("xray_transport"); // raw | xhttp | mkcp + + // Transport — XHTTP + constexpr QLatin1String xhttpMode("xhttp_mode"); // Auto | Packet-up | Stream-up | Stream-one + constexpr QLatin1String xhttpHost("xhttp_host"); + constexpr QLatin1String xhttpPath("xhttp_path"); + constexpr QLatin1String xhttpHeadersTemplate("xhttp_headers_template"); // HTTP | None + constexpr QLatin1String xhttpUplinkMethod("xhttp_uplink_method"); // POST | PUT | PATCH + constexpr QLatin1String xhttpDisableGrpc("xhttp_disable_grpc"); // bool + constexpr QLatin1String xhttpDisableSse("xhttp_disable_sse"); // bool + + // Transport — XHTTP Session & Sequence + constexpr QLatin1String xhttpSessionPlacement("xhttp_session_placement"); // Path | Header | Cookie | None + constexpr QLatin1String xhttpSessionKey("xhttp_session_key"); + constexpr QLatin1String xhttpSeqPlacement("xhttp_seq_placement"); + constexpr QLatin1String xhttpSeqKey("xhttp_seq_key"); + constexpr QLatin1String xhttpUplinkDataPlacement("xhttp_uplink_data_placement"); // Body | Query + constexpr QLatin1String xhttpUplinkDataKey("xhttp_uplink_data_key"); + + // Transport — XHTTP Traffic Shaping + constexpr QLatin1String xhttpUplinkChunkSize("xhttp_uplink_chunk_size"); + constexpr QLatin1String xhttpScMaxBufferedPosts("xhttp_sc_max_buffered_posts"); + constexpr QLatin1String xhttpScMaxEachPostBytesMin("xhttp_sc_max_each_post_bytes_min"); + constexpr QLatin1String xhttpScMaxEachPostBytesMax("xhttp_sc_max_each_post_bytes_max"); + constexpr QLatin1String xhttpScMinPostsIntervalMsMin("xhttp_sc_min_posts_interval_ms_min"); + constexpr QLatin1String xhttpScMinPostsIntervalMsMax("xhttp_sc_min_posts_interval_ms_max"); + constexpr QLatin1String xhttpScStreamUpServerSecsMin("xhttp_sc_stream_up_server_secs_min"); + constexpr QLatin1String xhttpScStreamUpServerSecsMax("xhttp_sc_stream_up_server_secs_max"); + + // Transport — mKCP + constexpr QLatin1String mkcpTti("mkcp_tti"); + constexpr QLatin1String mkcpUplinkCapacity("mkcp_uplink_capacity"); + constexpr QLatin1String mkcpDownlinkCapacity("mkcp_downlink_capacity"); + constexpr QLatin1String mkcpReadBufferSize("mkcp_read_buffer_size"); + constexpr QLatin1String mkcpWriteBufferSize("mkcp_write_buffer_size"); + constexpr QLatin1String mkcpCongestion("mkcp_congestion"); // bool + + // xPadding + constexpr QLatin1String xPaddingBytesMin("xpadding_bytes_min"); + constexpr QLatin1String xPaddingBytesMax("xpadding_bytes_max"); + constexpr QLatin1String xPaddingObfsMode("xpadding_obfs_mode"); // bool + constexpr QLatin1String xPaddingKey("xpadding_key"); + constexpr QLatin1String xPaddingHeader("xpadding_header"); + constexpr QLatin1String xPaddingPlacement("xpadding_placement"); // Cookie | Header | Query | Body + constexpr QLatin1String xPaddingMethod("xpadding_method"); // Repeat-x | Random | Zero + + // xmux + constexpr QLatin1String xmuxEnabled("xmux_enabled"); // bool + constexpr QLatin1String xmuxMaxConcurrencyMin("xmux_max_concurrency_min"); + constexpr QLatin1String xmuxMaxConcurrencyMax("xmux_max_concurrency_max"); + constexpr QLatin1String xmuxMaxConnectionsMin("xmux_max_connections_min"); + constexpr QLatin1String xmuxMaxConnectionsMax("xmux_max_connections_max"); + constexpr QLatin1String xmuxCMaxReuseTimesMin("xmux_c_max_reuse_times_min"); + constexpr QLatin1String xmuxCMaxReuseTimesMax("xmux_c_max_reuse_times_max"); + constexpr QLatin1String xmuxHMaxRequestTimesMin("xmux_h_max_request_times_min"); + constexpr QLatin1String xmuxHMaxRequestTimesMax("xmux_h_max_request_times_max"); + constexpr QLatin1String xmuxHMaxReusableSecsMin("xmux_h_max_reusable_secs_min"); + constexpr QLatin1String xmuxHMaxReusableSecsMax("xmux_h_max_reusable_secs_max"); + constexpr QLatin1String xmuxHKeepAlivePeriod("xmux_h_keep_alive_period"); } } diff --git a/client/core/utils/constants/protocolConstants.h b/client/core/utils/constants/protocolConstants.h index 01e2a151a..5c65d881e 100644 --- a/client/core/utils/constants/protocolConstants.h +++ b/client/core/utils/constants/protocolConstants.h @@ -3,6 +3,7 @@ namespace amnezia { + namespace protocols { @@ -57,6 +58,40 @@ namespace amnezia constexpr char defaultPort[] = "443"; constexpr char defaultLocalProxyPort[] = "10808"; constexpr char defaultLocalAddr[] = "10.33.0.2"; + constexpr char defaultLocalListenAddr[] = "127.0.0.1"; + + constexpr char defaultSecurity[] = "reality"; + constexpr char defaultFlow[] = "xtls-rprx-vision"; + constexpr char defaultTransport[] = "raw"; + constexpr char defaultFingerprint[] = "chrome"; + constexpr char defaultSni[] = "cdn.example.com"; + constexpr char defaultAlpn[] = "HTTP/2"; + + constexpr char defaultXhttpMode[] = "Auto"; + constexpr char defaultXhttpHeadersTemplate[] = "HTTP"; + constexpr char defaultXhttpUplinkMethod[] = "POST"; + constexpr char defaultXhttpSessionPlacement[] = "Path"; + constexpr char defaultXhttpSessionKey[] = "Path"; + constexpr char defaultXhttpSeqPlacement[] = "Path"; + constexpr char defaultXhttpUplinkDataPlacement[] = "Body"; + + constexpr char defaultXhttpHost[] = "www.googletagmanager.com"; + constexpr char defaultXhttpUplinkChunkSize[] = "0"; + constexpr char defaultXhttpScMaxEachPostBytesMin[] = "1"; + constexpr char defaultXhttpScMaxEachPostBytesMax[] = "100"; + constexpr char defaultXhttpScMinPostsIntervalMsMin[] = "100"; + constexpr char defaultXhttpScMinPostsIntervalMsMax[] = "800"; + constexpr char defaultXhttpScStreamUpServerSecsMin[] = "1"; + constexpr char defaultXhttpScStreamUpServerSecsMax[] = "100"; + + constexpr char defaultXPaddingPlacement[] = "Cookie"; + constexpr char defaultXPaddingMethod[] = "Repeat-x"; + + constexpr char defaultMkcpTti[] = "50"; + constexpr char defaultMkcpUplinkCapacity[] = "5"; + constexpr char defaultMkcpDownlinkCapacity[] = "20"; + constexpr char defaultMkcpReadBufferSize[] = "2"; + constexpr char defaultMkcpWriteBufferSize[] = "2"; constexpr char outbounds[] = "outbounds"; constexpr char inbounds[] = "inbounds"; @@ -174,9 +209,71 @@ 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 telemt + { + constexpr char secretKey[] = "telemt_secret"; + constexpr char tagKey[] = "telemt_tag"; + constexpr char tgLinkKey[] = "telemt_tg_link"; + constexpr char tmeLinkKey[] = "telemt_tme_link"; + constexpr char isEnabledKey[] = "telemt_is_enabled"; + constexpr char publicHostKey[] = "telemt_public_host"; + constexpr char transportModeKey[] = "telemt_transport_mode"; + constexpr char tlsDomainKey[] = "telemt_tls_domain"; + constexpr char maskEnabledKey[] = "telemt_mask_enabled"; + constexpr char tlsEmulationKey[] = "telemt_tls_emulation"; + constexpr char useMiddleProxyKey[] = "telemt_use_middle_proxy"; + constexpr char userNameKey[] = "telemt_user_name"; + // Stored for UI only (Telemt server ignores these; same controls as MTProxy page) + constexpr char additionalSecretsKey[] = "telemt_additional_secrets"; + constexpr char workersKey[] = "telemt_workers"; + constexpr char workersModeKey[] = "telemt_workers_mode"; + constexpr char natEnabledKey[] = "telemt_nat_enabled"; + constexpr char natInternalIpKey[] = "telemt_nat_internal_ip"; + constexpr char natExternalIpKey[] = "telemt_nat_external_ip"; + + constexpr char transportModeStandard[] = "standard"; + constexpr char transportModeFakeTLS[] = "faketls"; + + constexpr char defaultPort[] = "443"; + constexpr char defaultTlsDomain[] = "googletagmanager.com"; + constexpr char defaultUserName[] = "amnezia"; + constexpr char defaultWorkers[] = "2"; + constexpr char workersModeAuto[] = "auto"; + constexpr char workersModeManual[] = "manual"; + constexpr int maxWorkers = 32; + } + } // namespace protocols } #endif // PROTOCOLCONSTANTS_H - - diff --git a/client/core/utils/containerEnum.h b/client/core/utils/containerEnum.h index 97398b783..986aff92f 100644 --- a/client/core/utils/containerEnum.h +++ b/client/core/utils/containerEnum.h @@ -23,7 +23,9 @@ namespace amnezia TorWebSite, Dns, Sftp, - Socks5Proxy + Socks5Proxy, + MtProxy, + Telemt, }; Q_ENUM_NS(DockerContainer) } // namespace ContainerEnumNS diff --git a/client/core/utils/containers/containerUtils.cpp b/client/core/utils/containers/containerUtils.cpp index b028dcf5f..29664408f 100644 --- a/client/core/utils/containers/containerUtils.cpp +++ b/client/core/utils/containers/containerUtils.cpp @@ -72,7 +72,10 @@ 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)") }, + { DockerContainer::Telemt, QObject::tr("Telemt (Telegram)") }, + }; } QMap ContainerUtils::containerDescriptions() @@ -102,7 +105,12 @@ 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") }, + { DockerContainer::Telemt, + QObject::tr("Telegram MTProto proxy (Telemt, Rust)") }, + }; } QMap ContainerUtils::containerDetailedDescriptions() @@ -172,7 +180,15 @@ 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.") }, + { DockerContainer::Telemt, + QObject::tr("Telegram MTProto proxy powered by Telemt (Rust). " + "Supports secure and TLS fronting modes with optional traffic masking.") }, }; } @@ -197,6 +213,8 @@ 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; + case DockerContainer::Telemt: return Proto::Telemt; default: return Proto::Unknown; } } @@ -224,6 +242,8 @@ 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; + case DockerContainer::Telemt: return true; default: return false; } @@ -237,7 +257,8 @@ 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; + case DockerContainer::Telemt: return true; default: return false; } @@ -256,6 +277,8 @@ 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; + case DockerContainer::Telemt: return true; default: return false; } @@ -318,6 +341,8 @@ 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; + case DockerContainer::Telemt: return false; default: return true; } } @@ -346,8 +371,10 @@ int ContainerUtils::installPageOrder(DockerContainer container) case DockerContainer::Xray: return 3; case DockerContainer::Ipsec: return 7; case DockerContainer::SSXray: return 8; + case DockerContainer::MtProxy: + case DockerContainer::Telemt: + return 20; default: return 0; } } - diff --git a/client/core/utils/protocolEnum.h b/client/core/utils/protocolEnum.h index 2c5d9663b..19fdc67dc 100644 --- a/client/core/utils/protocolEnum.h +++ b/client/core/utils/protocolEnum.h @@ -30,7 +30,9 @@ namespace amnezia TorWebSite, Dns, Sftp, - Socks5Proxy + Socks5Proxy, + MtProxy, + Telemt, }; Q_ENUM_NS(Proto) diff --git a/client/core/utils/selfhosted/scriptsRegistry.cpp b/client/core/utils/selfhosted/scriptsRegistry.cpp index 26bf73de6..14c43eaab 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,8 @@ #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/telemtProtocolConfig.h" using namespace amnezia; using namespace ProtocolUtils; @@ -38,6 +39,8 @@ 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"); + case DockerContainer::Telemt: return QLatin1String("telemt"); default: return QString(); } } @@ -284,6 +287,86 @@ 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::genTelemtVars(const ContainerConfig &containerConfig) +{ + ScriptVars vars; + + if (auto *telemtProtocolConfig = containerConfig.getTelemtProtocolConfig()) { + const TelemtProtocolConfig &c = *telemtProtocolConfig; + + const QString transport = c.transportMode.isEmpty() ? QString(protocols::telemt::transportModeStandard) + : c.transportMode; + const bool faketls = (transport == QLatin1String(protocols::telemt::transportModeFakeTLS)); + vars.append({ { "$TELEMT_TOML_SECURE", faketls ? QLatin1String("false") : QLatin1String("true") } }); + vars.append({ { "$TELEMT_TOML_TLS", faketls ? QLatin1String("true") : QLatin1String("false") } }); + vars.append({ { "$TELEMT_PORT", c.port.isEmpty() ? QString(protocols::telemt::defaultPort) : c.port } }); + vars.append({ { "$TELEMT_SECRET", c.secret } }); + vars.append({ { "$TELEMT_TAG", c.tag } }); + QString tlsDomain = c.tlsDomain; + if (tlsDomain.isEmpty()) { + tlsDomain = QString(protocols::telemt::defaultTlsDomain); + } + vars.append({ { "$TELEMT_TLS_DOMAIN", tlsDomain } }); + vars.append({ { "$TELEMT_PUBLIC_HOST", c.publicHost } }); + vars.append({ { "$TELEMT_USER_NAME", + c.userName.isEmpty() ? QString::fromUtf8(protocols::telemt::defaultUserName) : c.userName } }); + vars.append({ { "$TELEMT_USE_MIDDLE_PROXY", c.useMiddleProxy ? QLatin1String("true") : QLatin1String("false") } }); + vars.append({ { "$TELEMT_MASK", c.maskEnabled ? QLatin1String("true") : QLatin1String("false") } }); + vars.append({ { "$TELEMT_TLS_EMULATION", c.tlsEmulation ? QLatin1String("true") : QLatin1String("false") } }); + } + + return vars; +} + amnezia::ScriptVars amnezia::genProtocolVarsForContainer(DockerContainer container, const ContainerConfig &containerConfig) { ScriptVars vars; @@ -308,6 +391,12 @@ amnezia::ScriptVars amnezia::genProtocolVarsForContainer(DockerContainer contain case Proto::Socks5Proxy: vars.append(genSocks5ProxyVars(containerConfig)); break; + case Proto::MtProxy: + vars.append(genMtProxyVars(containerConfig)); + break; + case Proto::Telemt: + vars.append(genTelemtVars(containerConfig)); + break; default: break; } diff --git a/client/core/utils/selfhosted/scriptsRegistry.h b/client/core/utils/selfhosted/scriptsRegistry.h index b9d320455..f63b850a6 100644 --- a/client/core/utils/selfhosted/scriptsRegistry.h +++ b/client/core/utils/selfhosted/scriptsRegistry.h @@ -67,6 +67,8 @@ 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 genTelemtVars(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..363745fd5 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 || container == DockerContainer::Telemt; + 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/main.cpp b/client/main.cpp index 621692bd7..a2c66f828 100644 --- a/client/main.cpp +++ b/client/main.cpp @@ -1,13 +1,12 @@ #include #include +#include #include "amneziaApplication.h" #include "core/utils/osSignalHandler.h" #include "core/utils/migrations.h" #include "version.h" -#include - #ifdef Q_OS_WIN #include "Windows.h" #endif @@ -47,6 +46,11 @@ int main(int argc, char *argv[]) AmneziaApplication app(argc, argv); OsSignalHandler::setup(); + ssh_init(); + QObject::connect(&app, &QCoreApplication::aboutToQuit, []() { + ssh_finalize(); + }); + #if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE) if (isAnotherInstanceRunning()) { QTimer::singleShot(1000, &app, [&]() { app.quit(); }); diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index c93dac460..e988ae43e 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -220,7 +220,7 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur m_rawConfig = configuration; m_serverAddress = configuration.value(configKey::hostName).toString().toNSString(); - const QString serverDescription = configuration.value(config_key::description).toString().trimmed(); + const QString serverDescription = configuration.value(configKey::description).toString().trimmed(); QString tunnelName; if (serverDescription.isEmpty()) { tunnelName = ProtocolUtils::protoToString(proto); 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/server_scripts/serverScripts.qrc b/client/server_scripts/serverScripts.qrc index 5ed57b01e..278e16953 100644 --- a/client/server_scripts/serverScripts.qrc +++ b/client/server_scripts/serverScripts.qrc @@ -24,6 +24,14 @@ ipsec/run_container.sh ipsec/start.sh ipsec/strongswan.profile + mtproxy/configure_container.sh + mtproxy/Dockerfile + mtproxy/run_container.sh + mtproxy/start.sh + telemt/configure_container.sh + telemt/Dockerfile + telemt/run_container.sh + telemt/start.sh openvpn/configure_container.sh openvpn/Dockerfile openvpn/run_container.sh @@ -55,4 +63,3 @@ xray/template.json - diff --git a/client/server_scripts/telemt/Dockerfile b/client/server_scripts/telemt/Dockerfile new file mode 100644 index 000000000..ad3f27365 --- /dev/null +++ b/client/server_scripts/telemt/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# Debian-based image with Telemt binary (shell + jq for Amnezia configure scripts). +# Binary from https://github.com/telemt/telemt releases (same pattern as upstream Dockerfile minimal stage). + +FROM debian:12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + binutils \ + ca-certificates \ + curl \ + jq \ + openssl \ + tar \ + && rm -rf /var/lib/apt/lists/* + +# Use machine arch (works with classic `docker build`; TARGETARCH is only set with BuildKit). +RUN set -eux; \ + ARCH="$(uname -m)"; \ + case "$ARCH" in \ + x86_64) ASSET="telemt-x86_64-linux-musl.tar.gz" ;; \ + aarch64|arm64) ASSET="telemt-aarch64-linux-musl.tar.gz" ;; \ + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ + esac; \ + curl -fL --retry 5 --retry-delay 3 --connect-timeout 10 --max-time 120 \ + -o "/tmp/${ASSET}" "https://github.com/telemt/telemt/releases/latest/download/${ASSET}"; \ + curl -fL --retry 5 --retry-delay 3 --connect-timeout 10 --max-time 120 \ + -o "/tmp/${ASSET}.sha256" "https://github.com/telemt/telemt/releases/latest/download/${ASSET}.sha256"; \ + cd /tmp && sha256sum -c "${ASSET}.sha256"; \ + tar -xzf "${ASSET}" -C /tmp; \ + test -f /tmp/telemt; \ + install -m 0755 /tmp/telemt /usr/local/bin/telemt; \ + strip --strip-unneeded /usr/local/bin/telemt || true; \ + rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt + +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/telemt/configure_container.sh b/client/server_scripts/telemt/configure_container.sh new file mode 100644 index 000000000..6cd9d31db --- /dev/null +++ b/client/server_scripts/telemt/configure_container.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Do not use set -e: Telemt / curl / kill edge cases should not abort the whole configure step. + +echo "[*] Amnezia Telemt: configure script start" +mkdir -p /data/tlsfront + +# Secret: substituted $TELEMT_SECRET -> saved file -> openssl (same rules as MTProxy configure) +if [ -n "$TELEMT_SECRET" ]; then + SECRET="$TELEMT_SECRET" +elif [ -f /data/secret ]; then + SECRET=$(cat /data/secret) +else + SECRET=$(openssl rand -hex 16) +fi +# Must be exactly 32 hex chars +echo "$SECRET" | grep -qE '^[0-9a-fA-F]{32}$' || SECRET=$(openssl rand -hex 16) + +# Build config.toml (other variables substituted on the host by Amnezia before upload) +rm -f /data/config.toml + +{ + echo "### Amnezia Telemt — generated" + echo "[general]" + echo "use_middle_proxy = $TELEMT_USE_MIDDLE_PROXY" + echo "log_level = \"normal\"" + if [ -n "$TELEMT_TAG" ]; then + echo "ad_tag = \"$TELEMT_TAG\"" + fi + echo "" + echo "[general.modes]" + echo "classic = false" + echo "secure = $TELEMT_TOML_SECURE" + echo "tls = $TELEMT_TOML_TLS" + echo "" + echo "[general.links]" + echo "show = \"*\"" + if [ -n "$TELEMT_PUBLIC_HOST" ]; then + echo "public_host = \"$TELEMT_PUBLIC_HOST\"" + fi + echo "public_port = $TELEMT_PORT" + echo "" + echo "[server]" + echo "port = $TELEMT_PORT" + echo "" + echo "[server.api]" + echo "enabled = true" + echo "listen = \"0.0.0.0:9091\"" + # Match upstream Telemt default: localhost API only (curl in this script uses 127.0.0.1). + echo "whitelist = [\"127.0.0.0/8\"]" + echo "" + echo "[[server.listeners]]" + echo "ip = \"0.0.0.0\"" + echo "" + echo "[censorship]" + echo "tls_domain = \"$TELEMT_TLS_DOMAIN\"" + echo "mask = $TELEMT_MASK" + echo "tls_emulation = $TELEMT_TLS_EMULATION" + echo "tls_front_dir = \"/data/tlsfront\"" + echo "" + echo "[access.users]" + echo "$TELEMT_USER_NAME = \"$SECRET\"" +} > /data/config.toml + +echo "$SECRET" > /data/secret +chmod 600 /data/secret 2>/dev/null || true + +# Do not start telemt here: a long-lived process + curl loop inside `docker exec` can confuse SSH/Docker +# timing and is unnecessary — start.sh runs telemt after configure. Links can be empty until the service +# is up; the client still parses Secret below. +echo "[*] Telemt configuration" +echo "[*] Secret: $SECRET" +echo "[*] tg:// link: " +echo "[*] t.me link: " diff --git a/client/server_scripts/telemt/run_container.sh b/client/server_scripts/telemt/run_container.sh new file mode 100644 index 000000000..24d3516e3 --- /dev/null +++ b/client/server_scripts/telemt/run_container.sh @@ -0,0 +1,9 @@ +# Run container (ulimit per Telemt docs — avoids "Too many open files" under load) +sudo docker run -d \ + --log-driver none \ + --restart always \ + --ulimit nofile=65536:65536 \ + -p $TELEMT_PORT:$TELEMT_PORT/tcp \ + -v amnezia-telemt-data:/data \ + --name $CONTAINER_NAME \ + $CONTAINER_NAME diff --git a/client/server_scripts/telemt/start.sh b/client/server_scripts/telemt/start.sh new file mode 100644 index 000000000..c7799aa4d --- /dev/null +++ b/client/server_scripts/telemt/start.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +echo "Container startup (Telemt)" + +if [ ! -f /data/config.toml ]; then + echo "ERROR: /data/config.toml not found — run configure_container first" + tail -f /dev/null + exit 1 +fi + +mkdir -p /data/tlsfront +exec /usr/local/bin/telemt /data/config.toml diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index e813b40f1..b1f95a688 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -1312,6 +1312,21 @@ Thank you for staying with us! PageProtocolXraySettings + + + XRay VLESS settings + Настройки XRay VLESS + + + + More about settings + Подробнее о настройках + + + + Reset settings + Сбросить настройки + XRay settings diff --git a/client/ui/controllers/importUiController.cpp b/client/ui/controllers/importUiController.cpp index ce9b952c1..db81c608c 100644 --- a/client/ui/controllers/importUiController.cpp +++ b/client/ui/controllers/importUiController.cpp @@ -201,3 +201,12 @@ bool ImportUiController::decodeQrCode(const QString &code) return mInstance->parseQrCodeChunk(code); } #endif + +QString ImportUiController::readTextFile(const QString &fileName) +{ + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + return QString::fromUtf8(file.readAll()); +} diff --git a/client/ui/controllers/importUiController.h b/client/ui/controllers/importUiController.h index 853539d05..c527e93c2 100644 --- a/client/ui/controllers/importUiController.h +++ b/client/ui/controllers/importUiController.h @@ -28,6 +28,7 @@ public slots: QString getMaliciousWarningText(); bool isNativeWireGuardConfig(); void processNativeWireGuardConfig(); + QString readTextFile(const QString &fileName); #if defined Q_OS_ANDROID || defined Q_OS_IOS void startDecodingQr(); 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 a02b00e25..f9bbc7972 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -53,6 +53,8 @@ namespace PageLoader PageServiceTorWebsiteSettings, PageServiceDnsSettings, PageServiceSocksProxySettings, + PageServiceMtProxySettings, + PageServiceTelemtSettings, PageSetupWizardStart, PageSetupWizardCredentials, @@ -83,7 +85,15 @@ namespace PageLoader PageSetupWizardApiPremiumInfo, PageSetupWizardApiTrialEmail, - PageDevMenu + PageDevMenu, + + PageProtocolXraySnapshots, + PageProtocolXrayTransportSettings, + PageProtocolXrayXmuxSettings, + PageProtocolXrayXPaddingSettings, + PageProtocolXrayFlowSettings, + PageProtocolXraySecuritySettings, + PageProtocolXrayXPaddingBytesSettings, }; Q_ENUM_NS(PageEnum) diff --git a/client/ui/controllers/selfhosted/exportUiController.cpp b/client/ui/controllers/selfhosted/exportUiController.cpp index 3e7984668..bbe1da570 100644 --- a/client/ui/controllers/selfhosted/exportUiController.cpp +++ b/client/ui/controllers/selfhosted/exportUiController.cpp @@ -3,6 +3,7 @@ #include #include "../systemController.h" +#include "core/utils/qrCodeUtils.h" ExportUiController::ExportUiController(ExportController* exportController, QObject *parent) : QObject(parent), @@ -53,6 +54,14 @@ void ExportUiController::generateXrayConfig(const QString &serverId, const QStri 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; @@ -118,3 +127,13 @@ void ExportUiController::applyExportResult(const ExportController::ExportResult emit exportConfigChanged(); } + +void ExportUiController::setConfigFromString(const QString &config, const QString &fileName) +{ + clearPreviousConfig(); + m_config = config; + emit exportConfigChanged(); + if (!fileName.isEmpty()) { + SystemController::saveFile(fileName, m_config); + } +} diff --git a/client/ui/controllers/selfhosted/exportUiController.h b/client/ui/controllers/selfhosted/exportUiController.h index 5bcac90bd..970ce7c08 100644 --- a/client/ui/controllers/selfhosted/exportUiController.h +++ b/client/ui/controllers/selfhosted/exportUiController.h @@ -25,12 +25,14 @@ public slots: void generateWireGuardConfig(const QString &serverId, const QString &clientName); void generateAwgConfig(const QString &serverId, int containerIndex, const QString &clientName); void generateXrayConfig(const QString &serverId, const QString &clientName); + void generateQrFromString(const QString &text); QString getConfig(); QString getNativeConfigString(); QList getQrCodes(); void exportConfig(const QString &fileName); + void setConfigFromString(const QString &config, const QString &fileName); void updateClientManagementModel(const QString &serverId, int containerIndex); diff --git a/client/ui/controllers/selfhosted/installUiController.cpp b/client/ui/controllers/selfhosted/installUiController.cpp old mode 100755 new mode 100644 index 32178e7a6..f413aac19 --- a/client/ui/controllers/selfhosted/installUiController.cpp +++ b/client/ui/controllers/selfhosted/installUiController.cpp @@ -5,11 +5,13 @@ #include #include #include +#include #include +#include +#include #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" @@ -47,6 +49,8 @@ InstallUiController::InstallUiController(InstallController *installController, #endif SftpConfigModel *sftpConfigModel, Socks5ProxyConfigModel *socks5ConfigModel, + MtProxyConfigModel* mtConfigModel, + TelemtConfigModel *telemtConfigModel, QObject *parent) : QObject(parent), m_installController(installController), @@ -63,7 +67,9 @@ InstallUiController::InstallUiController(InstallController *installController, m_ikev2ConfigModel(ikev2ConfigModel), #endif m_sftpConfigModel(sftpConfigModel), - m_socks5ConfigModel(socks5ConfigModel) + m_socks5ConfigModel(socks5ConfigModel), + m_mtProxyConfigModel(mtConfigModel), + m_telemtConfigModel(telemtConfigModel) { connect(m_installController, &InstallController::configValidated, this, &InstallUiController::configValidated); connect(m_installController, &InstallController::validationErrorOccurred, this, [this](ErrorCode errorCode) { @@ -199,7 +205,7 @@ void InstallUiController::scanServerForInstalledContainers(const QString &server emit installationErrorOccurred(errorCode); } -void InstallUiController::updateContainer(const QString &serverId, int containerIndex, int protocolIndex) +void InstallUiController::updateContainer(const QString &serverId, int containerIndex, int protocolIndex, bool closePage) { DockerContainer container = static_cast(containerIndex); @@ -238,6 +244,14 @@ void InstallUiController::updateContainer(const QString &serverId, int container containerConfig.protocolConfig = m_socks5ConfigModel->getProtocolConfig(); break; } + case Proto::MtProxy: { + containerConfig.protocolConfig = m_mtProxyConfigModel->getProtocolConfig(); + break; + } + case Proto::Telemt: { + containerConfig.protocolConfig = m_telemtConfigModel->getProtocolConfig(); + break; + } #ifdef Q_OS_WINDOWS case Proto::Ikev2: { containerConfig.protocolConfig = m_ikev2ConfigModel->getProtocolConfig(); @@ -249,19 +263,128 @@ void InstallUiController::updateContainer(const QString &serverId, int container } ContainerConfig oldContainerConfig = m_serversController->getContainerConfig(serverId, container); + if (container == DockerContainer::MtProxy || container == DockerContainer::Telemt) { + emit serverIsBusy(true); + auto *watcher = new QFutureWatcher(this); + QObject::connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, serverId, container, closePage]() { + const ErrorCode errorCode = watcher->result(); + watcher->deleteLater(); + emit serverIsBusy(false); + + if (errorCode == ErrorCode::NoError) { + const ContainerConfig updatedConfig = + m_serversController->getContainerConfig(serverId, container); + m_protocolModel->updateModel(updatedConfig); + + const auto defaultContainer = + m_serversController->getDefaultContainer(serverId); + if ((serverId == m_serversController->getDefaultServerId()) + && (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, serverId, container, oldConfigCopy, + newConfigCopy]() mutable -> ErrorCode { + return installController->updateContainer(serverId, container, oldConfigCopy, newConfigCopy); + }); + watcher->setFuture(future); + return; + } + ErrorCode errorCode = m_installController->updateContainer(serverId, container, oldContainerConfig, containerConfig); if (errorCode == ErrorCode::NoError) { ContainerConfig updatedConfig = m_serversController->getContainerConfig(serverId, container); m_protocolModel->updateModel(updatedConfig); - emit updateContainerFinished(tr("Settings updated successfully")); + const auto defaultContainer = m_serversController->getDefaultContainer(serverId); + if ((serverId == m_serversController->getDefaultServerId()) && (container == defaultContainer)) { + emit currentContainerUpdated(); + } else { + emit updateContainerFinished(tr("Settings updated successfully"), closePage); + } return; } emit installationErrorOccurred(errorCode); } +void InstallUiController::setContainerEnabled(const QString &serverId, int containerIndex, bool enabled) +{ + const DockerContainer container = static_cast(containerIndex); + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return; + } + + emit serverIsBusy(true); + const ErrorCode errorCode = m_installController->setDockerContainerEnabledState(serverId, container, enabled); + emit serverIsBusy(false); + + if (errorCode == ErrorCode::NoError) { + const ContainerConfig currentConfig = m_serversController->getContainerConfig(serverId, container); + m_protocolModel->updateModel(currentConfig); + emit setContainerEnabledFinished(enabled); + return; + } + + emit installationErrorOccurred(errorCode); +} + +void InstallUiController::refreshContainerStatus(const QString &serverId, int containerIndex) +{ + const DockerContainer container = static_cast(containerIndex); + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return; + } + + int status = 3; + const ErrorCode errorCode = m_installController->queryDockerContainerStatus(serverId, container, status); + if (errorCode != ErrorCode::NoError) { + emit containerStatusRefreshed(3); + return; + } + emit containerStatusRefreshed(status); +} + +void InstallUiController::refreshContainerDiagnostics(const QString &serverId, int containerIndex, int port) +{ + const DockerContainer container = static_cast(containerIndex); + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return; + } + + MtProxyContainerDiagnostics diag; + const ErrorCode errorCode = m_installController->queryMtProxyDiagnostics(serverId, container, port, diag); + if (errorCode != ErrorCode::NoError) { + emit containerDiagnosticsRefreshed(false, false, -1, QString(), QString()); + return; + } + emit containerDiagnosticsRefreshed(diag.portReachable, diag.upstreamReachable, diag.clientsConnected, + diag.lastConfigRefresh, diag.statsEndpoint); +} + +void InstallUiController::fetchContainerSecret(const QString &serverId, int containerIndex) +{ + const DockerContainer container = static_cast(containerIndex); + if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) { + return; + } + + const QString secret = m_installController->fetchDockerContainerSecret(serverId, container); + emit containerSecretFetched(secret); +} + void InstallUiController::rebootServer(const QString &serverId) { const QString serverName = m_serversController->notificationDisplayName(serverId); @@ -469,10 +592,13 @@ void InstallUiController::updateProtocolConfigModel(const QString &serverId, int case Proto::Awg: updateIfPresent(m_awgConfigModel, containerConfig.getAwgProtocolConfig()); break; case Proto::WireGuard: updateIfPresent(m_wireGuardConfigModel, containerConfig.getWireGuardProtocolConfig()); break; case Proto::OpenVpn: updateIfPresent(m_openVpnConfigModel, containerConfig.getOpenVpnProtocolConfig()); break; - case Proto::Xray: updateIfPresent(m_xrayConfigModel, containerConfig.getXrayProtocolConfig()); break; + case Proto::Xray: + case Proto::SSXray: updateIfPresent(m_xrayConfigModel, containerConfig.getXrayProtocolConfig()); break; 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; + case Proto::Telemt: updateIfPresent(m_telemtConfigModel, containerConfig.getTelemtProtocolConfig()); break; #ifdef Q_OS_WINDOWS case Proto::Ikev2: updateIfPresent(m_ikev2ConfigModel, containerConfig.getIkev2ProtocolConfig()); break; #endif diff --git a/client/ui/controllers/selfhosted/installUiController.h b/client/ui/controllers/selfhosted/installUiController.h index beec0a3b8..b0683552a 100644 --- a/client/ui/controllers/selfhosted/installUiController.h +++ b/client/ui/controllers/selfhosted/installUiController.h @@ -28,6 +28,8 @@ #include "ui/models/services/torConfigModel.h" #include "core/models/protocols/sftpProtocolConfig.h" #include "core/models/protocols/socks5ProxyProtocolConfig.h" +#include "ui/models/services/mtProxyConfigModel.h" +#include "ui/models/services/telemtConfigModel.h" class InstallUiController : public QObject { @@ -48,6 +50,8 @@ public: #endif SftpConfigModel* sftpConfigModel, Socks5ProxyConfigModel* socks5ConfigModel, + MtProxyConfigModel* mtConfigModel, + TelemtConfigModel* telemtConfigModel, QObject *parent = nullptr); ~InstallUiController(); @@ -58,12 +62,16 @@ public slots: void scanServerForInstalledContainers(const QString &serverId); - void updateContainer(const QString &serverId, int containerIndex, int protocolIndex); + void updateContainer(const QString &serverId, int containerIndex, int protocolIndex, bool closePage = true); void removeServer(const QString &serverId); void rebootServer(const QString &serverId); void removeAllContainers(const QString &serverId); void removeContainer(const QString &serverId, int containerIndex); + void setContainerEnabled(const QString &serverId, int containerIndex, bool enabled); + void refreshContainerStatus(const QString &serverId, int containerIndex); + void refreshContainerDiagnostics(const QString &serverId, int containerIndex, int port); + void fetchContainerSecret(const QString &serverId, int containerIndex); void clearCachedProfile(const QString &serverId, int containerIndex); @@ -94,7 +102,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 +110,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); @@ -114,6 +127,8 @@ signals: void serverIsBusy(const bool isBusy); void cancelInstallation(); + void currentContainerUpdated(); + void cachedProfileCleared(const QString &message); void apiConfigRemoved(const QString &message); @@ -138,6 +153,8 @@ private: #endif SftpConfigModel* m_sftpConfigModel; Socks5ProxyConfigModel* m_socks5ConfigModel; + MtProxyConfigModel* m_mtProxyConfigModel; + TelemtConfigModel* m_telemtConfigModel; ServerCredentials m_processedServerCredentials; diff --git a/client/ui/controllers/serversUiController.cpp b/client/ui/controllers/serversUiController.cpp index a5c0741f5..23097ddcc 100644 --- a/client/ui/controllers/serversUiController.cpp +++ b/client/ui/controllers/serversUiController.cpp @@ -1,6 +1,5 @@ #include "serversUiController.h" -#include "core/utils/api/apiUtils.h" #include "core/utils/containerEnum.h" #include "core/utils/containers/containerUtils.h" #include "core/utils/protocolEnum.h" @@ -216,11 +215,7 @@ QString ServersUiController::getDefaultServerImagePathCollapsed() const if (!description.isApiV2 || description.apiServerCountryCode.isEmpty()) { return ""; } - const QString imageCode = apiUtils::countryCodeBaseForFlag(description.apiServerCountryCode.toUpper()); - if (imageCode.isEmpty()) { - return QString(); - } - return QString("qrc:/countriesFlags/images/flagKit/%1.svg").arg(imageCode); + return QString("qrc:/countriesFlags/images/flagKit/%1.svg").arg(description.apiServerCountryCode.toUpper()); } } return ""; @@ -510,6 +505,10 @@ 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"); + } else if (container == DockerContainer::Telemt) { + servicesName.append("Telemt"); } } } diff --git a/client/ui/models/api/apiCountryModel.cpp b/client/ui/models/api/apiCountryModel.cpp index b1154e434..b0315f346 100644 --- a/client/ui/models/api/apiCountryModel.cpp +++ b/client/ui/models/api/apiCountryModel.cpp @@ -5,7 +5,6 @@ #include "core/utils/serverConfigUtils.h" #include "core/utils/constants/apiKeys.h" #include "core/utils/constants/apiConstants.h" -#include "core/utils/api/apiUtils.h" #include "logger.h" namespace @@ -42,7 +41,7 @@ QVariant ApiCountryModel::data(const QModelIndex &index, int role) const return countryInfo.countryName; } case CountryImageCodeRole: { - return apiUtils::countryCodeBaseForFlag(countryInfo.countryCode); + return countryInfo.countryCode.toUpper(); } case IsIssuedRole: { return isIssued; diff --git a/client/ui/models/containersModel.cpp b/client/ui/models/containersModel.cpp index 335ddbe7c..e176ac167 100644 --- a/client/ui/models/containersModel.cpp +++ b/client/ui/models/containersModel.cpp @@ -74,6 +74,8 @@ 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 IsTelemtRole: return container == DockerContainer::Telemt; case InstallPageOrderRole: return ContainerUtils::installPageOrder(container); } @@ -184,5 +186,7 @@ QHash ContainersModel::roleNames() const roles[IsSftpRole] = "isSftp"; roles[IsTorWebsiteRole] = "isTorWebsite"; roles[IsSocks5ProxyRole] = "isSocks5Proxy"; + roles[IsMtProxyRole] = "isMtProxy"; + roles[IsTelemtRole] = "isTelemt"; return roles; } diff --git a/client/ui/models/containersModel.h b/client/ui/models/containersModel.h index e5f71b01d..eec1be794 100644 --- a/client/ui/models/containersModel.h +++ b/client/ui/models/containersModel.h @@ -48,7 +48,9 @@ public: IsDnsRole, IsSftpRole, IsTorWebsiteRole, - IsSocks5ProxyRole + IsSocks5ProxyRole, + IsMtProxyRole, + IsTelemtRole, }; Q_INVOKABLE void openContainerSettings(int containerIndex); diff --git a/client/ui/models/protocols/xrayConfigModel.cpp b/client/ui/models/protocols/xrayConfigModel.cpp index 462982073..9a24759c9 100644 --- a/client/ui/models/protocols/xrayConfigModel.cpp +++ b/client/ui/models/protocols/xrayConfigModel.cpp @@ -8,94 +8,575 @@ using namespace amnezia; using namespace ProtocolUtils; -XrayConfigModel::XrayConfigModel(QObject *parent) : QAbstractListModel(parent) +XrayConfigModel::XrayConfigModel(QObject* parent) : QAbstractListModel(parent) { } -int XrayConfigModel::rowCount(const QModelIndex &parent) const +int XrayConfigModel::rowCount(const QModelIndex& parent) const { Q_UNUSED(parent); return 1; } -bool XrayConfigModel::setData(const QModelIndex &index, const QVariant &value, int role) +bool XrayConfigModel::setData(const QModelIndex& index, const QVariant& value, int role) { - if (!index.isValid() || index.row() < 0 || index.row() >= ContainerUtils::allContainers().size()) { + // This model always has a single row (row 0). Using rowCount() avoids + // coupling editing ability to global container list size. + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) + { return false; } - QString strValue = value.toString(); + const bool wasUnsavedChanges = hasUnsavedChanges(); + + auto& srv = m_protocolConfig.serverConfig; + auto& xhttp = srv.xhttp; + auto& mkcp = srv.mkcp; + auto& pad = xhttp.xPadding; + auto& mux = xhttp.xmux; + + QString str = value.toString(); + + switch (role) + { + // ── Main ────────────────────────────────────────────────────────── + case Roles::SiteRole: srv.site = str; + break; + case Roles::PortRole: srv.port = str; + break; + case Roles::TransportRole: srv.transport = str; + break; + case Roles::SecurityRole: srv.security = str; + break; + case Roles::FlowRole: srv.flow = str; + break; + + // ── Security ────────────────────────────────────────────────────── + case Roles::FingerprintRole: srv.fingerprint = str; + break; + case Roles::SniRole: srv.sni = str; + break; + case Roles::AlpnRole: srv.alpn = str; + break; + + // ── XHTTP ───────────────────────────────────────────────────────── + case Roles::XhttpModeRole: xhttp.mode = str; + break; + case Roles::XhttpHostRole: xhttp.host = str; + break; + case Roles::XhttpPathRole: xhttp.path = str; + break; + case Roles::XhttpHeadersTemplateRole: xhttp.headersTemplate = str; + break; + case Roles::XhttpUplinkMethodRole: xhttp.uplinkMethod = str; + break; + case Roles::XhttpDisableGrpcRole: xhttp.disableGrpc = value.toBool(); + break; + case Roles::XhttpDisableSseRole: xhttp.disableSse = value.toBool(); + break; + + case Roles::XhttpSessionPlacementRole: xhttp.sessionPlacement = str; + break; + case Roles::XhttpSessionKeyRole: xhttp.sessionKey = str; + break; + case Roles::XhttpSeqPlacementRole: xhttp.seqPlacement = str; + break; + case Roles::XhttpSeqKeyRole: xhttp.seqKey = str; + break; + case Roles::XhttpUplinkDataPlacementRole: xhttp.uplinkDataPlacement = str; + break; + case Roles::XhttpUplinkDataKeyRole: xhttp.uplinkDataKey = str; + break; + + case Roles::XhttpUplinkChunkSizeRole: xhttp.uplinkChunkSize = str; + break; + case Roles::XhttpScMaxBufferedPostsRole: xhttp.scMaxBufferedPosts = str; + break; + case Roles::XhttpScMaxEachPostBytesMinRole: xhttp.scMaxEachPostBytesMin = str; + break; + case Roles::XhttpScMaxEachPostBytesMaxRole: xhttp.scMaxEachPostBytesMax = str; + break; + case Roles::XhttpScMinPostsIntervalMsMinRole: xhttp.scMinPostsIntervalMsMin = str; + break; + case Roles::XhttpScMinPostsIntervalMsMaxRole: xhttp.scMinPostsIntervalMsMax = str; + break; + case Roles::XhttpScStreamUpServerSecsMinRole: xhttp.scStreamUpServerSecsMin = str; + break; + case Roles::XhttpScStreamUpServerSecsMaxRole: xhttp.scStreamUpServerSecsMax = str; + break; + + // ── mKCP ────────────────────────────────────────────────────────── + case Roles::MkcpTtiRole: mkcp.tti = str; + break; + case Roles::MkcpUplinkCapacityRole: mkcp.uplinkCapacity = str; + break; + case Roles::MkcpDownlinkCapacityRole: mkcp.downlinkCapacity = str; + break; + case Roles::MkcpReadBufferSizeRole: mkcp.readBufferSize = str; + break; + case Roles::MkcpWriteBufferSizeRole: mkcp.writeBufferSize = str; + break; + case Roles::MkcpCongestionRole: mkcp.congestion = value.toBool(); + break; + + // ── xPadding ────────────────────────────────────────────────────── + case Roles::XPaddingBytesMinRole: pad.bytesMin = str; + break; + case Roles::XPaddingBytesMaxRole: pad.bytesMax = str; + break; + case Roles::XPaddingObfsModeRole: pad.obfsMode = value.toBool(); + break; + case Roles::XPaddingKeyRole: pad.key = str; + break; + case Roles::XPaddingHeaderRole: pad.header = str; + break; + case Roles::XPaddingPlacementRole: pad.placement = str; + break; + case Roles::XPaddingMethodRole: pad.method = str; + break; + + // ── xmux ────────────────────────────────────────────────────────── + case Roles::XmuxEnabledRole: mux.enabled = value.toBool(); + break; + case Roles::XmuxMaxConcurrencyMinRole: mux.maxConcurrencyMin = str; + break; + case Roles::XmuxMaxConcurrencyMaxRole: mux.maxConcurrencyMax = str; + break; + case Roles::XmuxMaxConnectionsMinRole: mux.maxConnectionsMin = str; + break; + case Roles::XmuxMaxConnectionsMaxRole: mux.maxConnectionsMax = str; + break; + case Roles::XmuxCMaxReuseTimesMinRole: mux.cMaxReuseTimesMin = str; + break; + case Roles::XmuxCMaxReuseTimesMaxRole: mux.cMaxReuseTimesMax = str; + break; + case Roles::XmuxHMaxRequestTimesMinRole: mux.hMaxRequestTimesMin = str; + break; + case Roles::XmuxHMaxRequestTimesMaxRole: mux.hMaxRequestTimesMax = str; + break; + case Roles::XmuxHMaxReusableSecsMinRole: mux.hMaxReusableSecsMin = str; + break; + case Roles::XmuxHMaxReusableSecsMaxRole: mux.hMaxReusableSecsMax = str; + break; + case Roles::XmuxHKeepAlivePeriodRole: mux.hKeepAlivePeriod = str; + break; - switch (role) { - case Roles::SiteRole: m_protocolConfig.serverConfig.site = strValue; break; - case Roles::PortRole: m_protocolConfig.serverConfig.port = strValue; break; default: return false; } - emit dataChanged(index, index, QList { role }); + emit dataChanged(index, index, QList{role}); + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } return true; } -QVariant XrayConfigModel::data(const QModelIndex &index, int role) const +QVariant XrayConfigModel::data(const QModelIndex& index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) { return QVariant(); } - switch (role) { - case Roles::SiteRole: return m_protocolConfig.serverConfig.site; - case Roles::PortRole: return m_protocolConfig.serverConfig.port; + const auto& srv = m_protocolConfig.serverConfig; + const auto& xhttp = srv.xhttp; + const auto& mkcp = srv.mkcp; + const auto& pad = xhttp.xPadding; + const auto& mux = xhttp.xmux; + + switch (role) + { + // ── Main ────────────────────────────────────────────────────────── + case Roles::SiteRole: return srv.site; + case Roles::PortRole: return srv.port; + case Roles::TransportRole: return srv.transport; + case Roles::SecurityRole: return srv.security; + case Roles::FlowRole: return srv.flow; + + // ── Security ────────────────────────────────────────────────────── + case Roles::FingerprintRole: return srv.fingerprint; + case Roles::SniRole: return srv.sni; + case Roles::AlpnRole: return srv.alpn; + + // ── XHTTP ───────────────────────────────────────────────────────── + case Roles::XhttpModeRole: return xhttp.mode; + case Roles::XhttpHostRole: return xhttp.host; + case Roles::XhttpPathRole: return xhttp.path; + case Roles::XhttpHeadersTemplateRole: return xhttp.headersTemplate; + case Roles::XhttpUplinkMethodRole: return xhttp.uplinkMethod; + case Roles::XhttpDisableGrpcRole: return xhttp.disableGrpc; + case Roles::XhttpDisableSseRole: return xhttp.disableSse; + + case Roles::XhttpSessionPlacementRole: return xhttp.sessionPlacement; + case Roles::XhttpSessionKeyRole: return xhttp.sessionKey; + case Roles::XhttpSeqPlacementRole: return xhttp.seqPlacement; + case Roles::XhttpSeqKeyRole: return xhttp.seqKey; + case Roles::XhttpUplinkDataPlacementRole: return xhttp.uplinkDataPlacement; + case Roles::XhttpUplinkDataKeyRole: return xhttp.uplinkDataKey; + + case Roles::XhttpUplinkChunkSizeRole: return xhttp.uplinkChunkSize; + case Roles::XhttpScMaxBufferedPostsRole: return xhttp.scMaxBufferedPosts; + case Roles::XhttpScMaxEachPostBytesMinRole: return xhttp.scMaxEachPostBytesMin; + case Roles::XhttpScMaxEachPostBytesMaxRole: return xhttp.scMaxEachPostBytesMax; + case Roles::XhttpScMinPostsIntervalMsMinRole: return xhttp.scMinPostsIntervalMsMin; + case Roles::XhttpScMinPostsIntervalMsMaxRole: return xhttp.scMinPostsIntervalMsMax; + case Roles::XhttpScStreamUpServerSecsMinRole: return xhttp.scStreamUpServerSecsMin; + case Roles::XhttpScStreamUpServerSecsMaxRole: return xhttp.scStreamUpServerSecsMax; + + // ── mKCP ────────────────────────────────────────────────────────── + case Roles::MkcpTtiRole: return mkcp.tti; + case Roles::MkcpUplinkCapacityRole: return mkcp.uplinkCapacity; + case Roles::MkcpDownlinkCapacityRole: return mkcp.downlinkCapacity; + case Roles::MkcpReadBufferSizeRole: return mkcp.readBufferSize; + case Roles::MkcpWriteBufferSizeRole: return mkcp.writeBufferSize; + case Roles::MkcpCongestionRole: return mkcp.congestion; + + // ── xPadding ────────────────────────────────────────────────────── + case Roles::XPaddingBytesMinRole: return pad.bytesMin; + case Roles::XPaddingBytesMaxRole: return pad.bytesMax; + case Roles::XPaddingObfsModeRole: return pad.obfsMode; + case Roles::XPaddingKeyRole: return pad.key; + case Roles::XPaddingHeaderRole: return pad.header; + case Roles::XPaddingPlacementRole: return pad.placement; + case Roles::XPaddingMethodRole: return pad.method; + + // ── xmux ────────────────────────────────────────────────────────── + case Roles::XmuxEnabledRole: return mux.enabled; + case Roles::XmuxMaxConcurrencyMinRole: return mux.maxConcurrencyMin; + case Roles::XmuxMaxConcurrencyMaxRole: return mux.maxConcurrencyMax; + case Roles::XmuxMaxConnectionsMinRole: return mux.maxConnectionsMin; + case Roles::XmuxMaxConnectionsMaxRole: return mux.maxConnectionsMax; + case Roles::XmuxCMaxReuseTimesMinRole: return mux.cMaxReuseTimesMin; + case Roles::XmuxCMaxReuseTimesMaxRole: return mux.cMaxReuseTimesMax; + case Roles::XmuxHMaxRequestTimesMinRole: return mux.hMaxRequestTimesMin; + case Roles::XmuxHMaxRequestTimesMaxRole: return mux.hMaxRequestTimesMax; + case Roles::XmuxHMaxReusableSecsMinRole: return mux.hMaxReusableSecsMin; + case Roles::XmuxHMaxReusableSecsMaxRole: return mux.hMaxReusableSecsMax; + case Roles::XmuxHKeepAlivePeriodRole: return mux.hKeepAlivePeriod; } return QVariant(); } -void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig &protocolConfig) +void XrayConfigModel::updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig& protocolConfig) { + const bool wasUnsavedChanges = hasUnsavedChanges(); + beginResetModel(); + m_container = container; - + m_protocolConfig = protocolConfig; - + applyDefaultsToServerConfig(m_protocolConfig.serverConfig); - + m_originalProtocolConfig = m_protocolConfig; - + endResetModel(); + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } } -void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig& config) +void XrayConfigModel::applyDefaultsToServerConfig(amnezia::XrayServerConfig &config) { if (config.port.isEmpty()) { config.port = protocols::xray::defaultPort; } + if (config.transportProto.isEmpty()) { config.transportProto = ProtocolUtils::transportProtoToString( ProtocolUtils::defaultTransportProto(amnezia::Proto::Xray), amnezia::Proto::Xray); } + if (config.site.isEmpty()) { config.site = protocols::xray::defaultSite; } + + if (config.transport.isEmpty()) { + config.transport = protocols::xray::defaultTransport; + } + + if (config.security.isEmpty()) { + config.security = protocols::xray::defaultSecurity; + } + + if (config.flow.isEmpty()) { + config.flow = protocols::xray::defaultFlow; + } + + if (config.fingerprint.isEmpty()) { + config.fingerprint = protocols::xray::defaultFingerprint; + } else if (config.fingerprint.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) { + config.fingerprint = QString::fromLatin1(protocols::xray::defaultFingerprint); + } + + if (config.sni.isEmpty()) { + config.sni = protocols::xray::defaultSni; + } + + if (config.alpn.isEmpty()) { + config.alpn = protocols::xray::defaultAlpn; + } + + // XHTTP transport defaults + if (config.xhttp.host.isEmpty()) { + config.xhttp.host = protocols::xray::defaultXhttpHost; + } + if (config.xhttp.mode.isEmpty()) { + config.xhttp.mode = protocols::xray::defaultXhttpMode; + } + if (config.xhttp.headersTemplate.isEmpty()) { + config.xhttp.headersTemplate = protocols::xray::defaultXhttpHeadersTemplate; + } + if (config.xhttp.uplinkMethod.isEmpty()) { + config.xhttp.uplinkMethod = protocols::xray::defaultXhttpUplinkMethod; + } + if (config.xhttp.sessionPlacement.isEmpty()) { + config.xhttp.sessionPlacement = protocols::xray::defaultXhttpSessionPlacement; + } + if (config.xhttp.sessionKey.isEmpty()) { + config.xhttp.sessionKey = protocols::xray::defaultXhttpSessionKey; + } + if (config.xhttp.seqPlacement.isEmpty()) { + config.xhttp.seqPlacement = protocols::xray::defaultXhttpSeqPlacement; + } + if (config.xhttp.uplinkDataPlacement.isEmpty()) { + config.xhttp.uplinkDataPlacement = protocols::xray::defaultXhttpUplinkDataPlacement; + } + + // xPadding defaults + if (config.xhttp.xPadding.placement.isEmpty()) { + config.xhttp.xPadding.placement = protocols::xray::defaultXPaddingPlacement; + } + if (config.xhttp.xPadding.method.isEmpty()) { + config.xhttp.xPadding.method = protocols::xray::defaultXPaddingMethod; + } } amnezia::XrayProtocolConfig XrayConfigModel::getProtocolConfig() { - bool serverSettingsChanged = !m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); - + const bool serverSettingsChanged = + !m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); + if (serverSettingsChanged) { m_protocolConfig.clearClientConfig(); } - return m_protocolConfig; } +bool XrayConfigModel::isServerSettingsEqual() const +{ + return m_protocolConfig.serverConfig.hasEqualServerSettings(m_originalProtocolConfig.serverConfig); +} + +bool XrayConfigModel::hasUnsavedChanges() const +{ + return !isServerSettingsEqual(); +} + QHash XrayConfigModel::roleNames() const { QHash roles; + // Main roles[SiteRole] = "site"; roles[PortRole] = "port"; + roles[TransportRole] = "transport"; + roles[SecurityRole] = "security"; + roles[FlowRole] = "flow"; + + // Security + roles[FingerprintRole] = "fingerprint"; + roles[SniRole] = "sni"; + roles[AlpnRole] = "alpn"; + + // XHTTP + roles[XhttpModeRole] = "xhttpMode"; + roles[XhttpHostRole] = "xhttpHost"; + roles[XhttpPathRole] = "xhttpPath"; + roles[XhttpHeadersTemplateRole] = "xhttpHeadersTemplate"; + roles[XhttpUplinkMethodRole] = "xhttpUplinkMethod"; + roles[XhttpDisableGrpcRole] = "xhttpDisableGrpc"; + roles[XhttpDisableSseRole] = "xhttpDisableSse"; + + roles[XhttpSessionPlacementRole] = "xhttpSessionPlacement"; + roles[XhttpSessionKeyRole] = "xhttpSessionKey"; + roles[XhttpSeqPlacementRole] = "xhttpSeqPlacement"; + roles[XhttpSeqKeyRole] = "xhttpSeqKey"; + roles[XhttpUplinkDataPlacementRole] = "xhttpUplinkDataPlacement"; + roles[XhttpUplinkDataKeyRole] = "xhttpUplinkDataKey"; + + roles[XhttpUplinkChunkSizeRole] = "xhttpUplinkChunkSize"; + roles[XhttpScMaxBufferedPostsRole] = "xhttpScMaxBufferedPosts"; + roles[XhttpScMaxEachPostBytesMinRole] = "xhttpScMaxEachPostBytesMin"; + roles[XhttpScMaxEachPostBytesMaxRole] = "xhttpScMaxEachPostBytesMax"; + roles[XhttpScMinPostsIntervalMsMinRole] = "xhttpScMinPostsIntervalMsMin"; + roles[XhttpScMinPostsIntervalMsMaxRole] = "xhttpScMinPostsIntervalMsMax"; + roles[XhttpScStreamUpServerSecsMinRole] = "xhttpScStreamUpServerSecsMin"; + roles[XhttpScStreamUpServerSecsMaxRole] = "xhttpScStreamUpServerSecsMax"; + + // mKCP + roles[MkcpTtiRole] = "mkcpTti"; + roles[MkcpUplinkCapacityRole] = "mkcpUplinkCapacity"; + roles[MkcpDownlinkCapacityRole] = "mkcpDownlinkCapacity"; + roles[MkcpReadBufferSizeRole] = "mkcpReadBufferSize"; + roles[MkcpWriteBufferSizeRole] = "mkcpWriteBufferSize"; + roles[MkcpCongestionRole] = "mkcpCongestion"; + + // xPadding + roles[XPaddingBytesMinRole] = "xPaddingBytesMin"; + roles[XPaddingBytesMaxRole] = "xPaddingBytesMax"; + roles[XPaddingObfsModeRole] = "xPaddingObfsMode"; + roles[XPaddingKeyRole] = "xPaddingKey"; + roles[XPaddingHeaderRole] = "xPaddingHeader"; + roles[XPaddingPlacementRole] = "xPaddingPlacement"; + roles[XPaddingMethodRole] = "xPaddingMethod"; + + // xmux + roles[XmuxEnabledRole] = "xmuxEnabled"; + roles[XmuxMaxConcurrencyMinRole] = "xmuxMaxConcurrencyMin"; + roles[XmuxMaxConcurrencyMaxRole] = "xmuxMaxConcurrencyMax"; + roles[XmuxMaxConnectionsMinRole] = "xmuxMaxConnectionsMin"; + roles[XmuxMaxConnectionsMaxRole] = "xmuxMaxConnectionsMax"; + roles[XmuxCMaxReuseTimesMinRole] = "xmuxCMaxReuseTimesMin"; + roles[XmuxCMaxReuseTimesMaxRole] = "xmuxCMaxReuseTimesMax"; + roles[XmuxHMaxRequestTimesMinRole] = "xmuxHMaxRequestTimesMin"; + roles[XmuxHMaxRequestTimesMaxRole] = "xmuxHMaxRequestTimesMax"; + roles[XmuxHMaxReusableSecsMinRole] = "xmuxHMaxReusableSecsMin"; + roles[XmuxHMaxReusableSecsMaxRole] = "xmuxHMaxReusableSecsMax"; + roles[XmuxHKeepAlivePeriodRole] = "xmuxHKeepAlivePeriod"; return roles; } + +void XrayConfigModel::resetToDefaults() +{ + const bool wasUnsavedChanges = hasUnsavedChanges(); + + beginResetModel(); + m_protocolConfig.serverConfig = amnezia::XrayServerConfig{}; + applyDefaultsToServerConfig(m_protocolConfig.serverConfig); + endResetModel(); + + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } +} + +void XrayConfigModel::applyServerConfig(const amnezia::XrayServerConfig &serverConfig) +{ + const bool wasUnsavedChanges = hasUnsavedChanges(); + + beginResetModel(); + m_protocolConfig.serverConfig = serverConfig; + // Clear client config since server settings changed + m_protocolConfig.clearClientConfig(); + m_originalProtocolConfig = m_protocolConfig; + endResetModel(); + + if (wasUnsavedChanges != hasUnsavedChanges()) { + emit hasUnsavedChangesChanged(); + } +} + +QStringList XrayConfigModel::flowOptions() +{ + return { + "", // Empty (no flow) + "xtls-rprx-vision", + "xtls-rprx-vision-udp443" + }; +} + +QStringList XrayConfigModel::securityOptions() +{ + return { "none", "tls", "reality" }; +} + +QStringList XrayConfigModel::transportOptions() +{ + return { "raw", "xhttp", "mkcp" }; +} + +QStringList XrayConfigModel::fingerprintOptions() +{ + return { "chrome", "firefox", "safari", "ios", "android", "edge", "360", "qq", "random" }; +} + +QStringList XrayConfigModel::alpnOptions() +{ + return { "HTTP/2", "HTTP/1.1", "HTTP/2,HTTP/1.1" }; +} + +QStringList XrayConfigModel::xhttpModeOptions() +{ + return { "Auto", "Packet-up", "Stream-up", "Stream-one" }; +} + +QStringList XrayConfigModel::xhttpHeadersTemplateOptions() +{ + return { "HTTP", "None" }; +} + +QStringList XrayConfigModel::xhttpUplinkMethodOptions() +{ + return { "POST", "PUT", "PATCH" }; +} + +QStringList XrayConfigModel::xhttpSessionPlacementOptions() +{ + return { "Path", "Header", "Cookie", "None" }; +} + +QStringList XrayConfigModel::xhttpSessionKeyOptions() +{ + return { "Path", "Header", "None" }; +} + +QStringList XrayConfigModel::xhttpSeqPlacementOptions() +{ + return { "Path", "Header", "Cookie", "None" }; +} + +QStringList XrayConfigModel::xhttpUplinkDataPlacementOptions() +{ + // Matches splithttp uplink payload placement (packet-up / advanced) + return { "Body", "Auto", "Header", "Cookie" }; +} + +QStringList XrayConfigModel::xPaddingPlacementOptions() +{ + // Xray-core: cookie | header | query | queryInHeader (not "body") + return { "Cookie", "Header", "Query", "Query in header" }; +} + +QStringList XrayConfigModel::xPaddingMethodOptions() +{ + return { "Repeat-x", "Tokenish" }; +} + +QString XrayConfigModel::mkcpDefaultTti() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpTti); +} + +QString XrayConfigModel::mkcpDefaultUplinkCapacity() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpUplinkCapacity); +} + +QString XrayConfigModel::mkcpDefaultDownlinkCapacity() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpDownlinkCapacity); +} + +QString XrayConfigModel::mkcpDefaultReadBufferSize() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpReadBufferSize); +} + +QString XrayConfigModel::mkcpDefaultWriteBufferSize() +{ + return QString::fromLatin1(protocols::xray::defaultMkcpWriteBufferSize); +} diff --git a/client/ui/models/protocols/xrayConfigModel.h b/client/ui/models/protocols/xrayConfigModel.h index 5549cf446..fee066107 100644 --- a/client/ui/models/protocols/xrayConfigModel.h +++ b/client/ui/models/protocols/xrayConfigModel.h @@ -2,6 +2,7 @@ #define XRAYCONFIGMODEL_H #include +#include #include "core/utils/containerEnum.h" #include "core/utils/containers/containerUtils.h" @@ -11,23 +12,122 @@ class XrayConfigModel : public QAbstractListModel { Q_OBJECT + Q_PROPERTY(bool hasUnsavedChanges READ hasUnsavedChanges NOTIFY hasUnsavedChangesChanged) public: - enum Roles { - SiteRole, - PortRole + enum Roles + { + // ── Main page ───────────────────────────────────────────────── + SiteRole = Qt::UserRole + 1, + PortRole, + TransportRole, // "raw" | "xhttp" | "mkcp" (display in main page row) + SecurityRole, // "none" | "tls" | "reality" (display in main page row) + FlowRole, // "" | "xtls-rprx-vision" | "xtls-rprx-vision-udp443" + + // ── Security ────────────────────────────────────────────────── + FingerprintRole, + SniRole, + AlpnRole, + + // ── Transport — XHTTP ───────────────────────────────────────── + XhttpModeRole, + XhttpHostRole, + XhttpPathRole, + XhttpHeadersTemplateRole, + XhttpUplinkMethodRole, + XhttpDisableGrpcRole, + XhttpDisableSseRole, + + // Session & Sequence + XhttpSessionPlacementRole, + XhttpSessionKeyRole, + XhttpSeqPlacementRole, + XhttpSeqKeyRole, + XhttpUplinkDataPlacementRole, + XhttpUplinkDataKeyRole, + + // Traffic Shaping + XhttpUplinkChunkSizeRole, + XhttpScMaxBufferedPostsRole, + XhttpScMaxEachPostBytesMinRole, + XhttpScMaxEachPostBytesMaxRole, + XhttpScMinPostsIntervalMsMinRole, + XhttpScMinPostsIntervalMsMaxRole, + XhttpScStreamUpServerSecsMinRole, + XhttpScStreamUpServerSecsMaxRole, + + // ── Transport — mKCP ────────────────────────────────────────── + MkcpTtiRole, + MkcpUplinkCapacityRole, + MkcpDownlinkCapacityRole, + MkcpReadBufferSizeRole, + MkcpWriteBufferSizeRole, + MkcpCongestionRole, + + // ── xPadding ────────────────────────────────────────────────── + XPaddingBytesMinRole, + XPaddingBytesMaxRole, + XPaddingObfsModeRole, + XPaddingKeyRole, + XPaddingHeaderRole, + XPaddingPlacementRole, + XPaddingMethodRole, + + // ── xmux ────────────────────────────────────────────────────── + XmuxEnabledRole, + XmuxMaxConcurrencyMinRole, + XmuxMaxConcurrencyMaxRole, + XmuxMaxConnectionsMinRole, + XmuxMaxConnectionsMaxRole, + XmuxCMaxReuseTimesMinRole, + XmuxCMaxReuseTimesMaxRole, + XmuxHMaxRequestTimesMinRole, + XmuxHMaxRequestTimesMaxRole, + XmuxHMaxReusableSecsMinRole, + XmuxHMaxReusableSecsMaxRole, + XmuxHKeepAlivePeriodRole, }; - explicit XrayConfigModel(QObject *parent = nullptr); + explicit XrayConfigModel(QObject* parent = nullptr); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; + 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; + bool setData(const QModelIndex& index, const QVariant& value, int role) override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + + // ── Static option lists (for QML DropDown models) ───────────────── + Q_INVOKABLE static QStringList flowOptions(); + Q_INVOKABLE static QStringList securityOptions(); + Q_INVOKABLE static QStringList transportOptions(); + Q_INVOKABLE static QStringList fingerprintOptions(); + Q_INVOKABLE static QStringList alpnOptions(); + Q_INVOKABLE static QStringList xhttpModeOptions(); + Q_INVOKABLE static QStringList xhttpHeadersTemplateOptions(); + Q_INVOKABLE static QStringList xhttpUplinkMethodOptions(); + Q_INVOKABLE static QStringList xhttpSessionPlacementOptions(); + Q_INVOKABLE static QStringList xhttpSessionKeyOptions(); + Q_INVOKABLE static QStringList xhttpSeqPlacementOptions(); + Q_INVOKABLE static QStringList xhttpUplinkDataPlacementOptions(); + Q_INVOKABLE static QStringList xPaddingPlacementOptions(); + Q_INVOKABLE static QStringList xPaddingMethodOptions(); + + // mKCP display defaults (protocolConstants.h — must match xrayConfigurator empty-field behavior) + Q_INVOKABLE static QString mkcpDefaultTti(); + Q_INVOKABLE static QString mkcpDefaultUplinkCapacity(); + Q_INVOKABLE static QString mkcpDefaultDownlinkCapacity(); + Q_INVOKABLE static QString mkcpDefaultReadBufferSize(); + Q_INVOKABLE static QString mkcpDefaultWriteBufferSize(); public slots: - void updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig &protocolConfig); + void updateModel(amnezia::DockerContainer container, const amnezia::XrayProtocolConfig& protocolConfig); amnezia::XrayProtocolConfig getProtocolConfig(); + bool isServerSettingsEqual() const; + bool hasUnsavedChanges() const; + void resetToDefaults(); + void applyServerConfig(const amnezia::XrayServerConfig &serverConfig); + +signals: + void hasUnsavedChangesChanged(); protected: QHash roleNames() const override; @@ -36,7 +136,7 @@ private: amnezia::DockerContainer m_container; amnezia::XrayProtocolConfig m_protocolConfig; amnezia::XrayProtocolConfig m_originalProtocolConfig; - + void applyDefaultsToServerConfig(amnezia::XrayServerConfig& config); }; diff --git a/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp b/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp new file mode 100644 index 000000000..8a023212f --- /dev/null +++ b/client/ui/models/protocols/xrayConfigSnapshotsModel.cpp @@ -0,0 +1,216 @@ +#include "xrayConfigSnapshotsModel.h" + +#include +#include + +#include "core/repositories/secureAppSettingsRepository.h" +#include "core/utils/constants/configKeys.h" + +QJsonObject XrayConfigSnapshot::toJson() const +{ + QJsonObject obj; + obj["id"] = id; + obj["displayName"] = displayName; + obj["createdAt"] = createdAt.toString(Qt::ISODate); + obj["serverConfig"] = serverConfig.toJson(); + return obj; +} + +XrayConfigSnapshot XrayConfigSnapshot::fromJson(const QJsonObject &json) +{ + XrayConfigSnapshot s; + s.id = json.value("id").toString(); + s.displayName = json.value("displayName").toString(); + s.createdAt = QDateTime::fromString(json.value("createdAt").toString(), Qt::ISODate); + s.serverConfig = amnezia::XrayServerConfig::fromJson(json.value("serverConfig").toObject()); + return s; +} + +XrayConfigSnapshotsModel::XrayConfigSnapshotsModel(SecureAppSettingsRepository *appSettings, + XrayConfigModel *xrayConfigModel, QObject *parent) + : QAbstractListModel(parent), m_appSettings(appSettings), m_xrayConfigModel(xrayConfigModel) +{ + loadAll(); +} + +void XrayConfigSnapshotsModel::loadAll() +{ + m_configs.clear(); + QByteArray raw = m_appSettings->xraySavedConfigs(); + if (raw.isEmpty()) { + return; + } + + QJsonArray arr = QJsonDocument::fromJson(raw).array(); + for (const QJsonValue &v : arr) { + m_configs.append(XrayConfigSnapshot::fromJson(v.toObject())); + } +} + +void XrayConfigSnapshotsModel::persistAll() +{ + QJsonArray arr; + for (const XrayConfigSnapshot &s : m_configs) { + arr.append(s.toJson()); + } + m_appSettings->setXraySavedConfigs(QJsonDocument(arr).toJson(QJsonDocument::Compact)); +} + +int XrayConfigSnapshotsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return m_configs.size(); +} + +QVariant XrayConfigSnapshotsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_configs.size()) { + return QVariant(); + } + + const XrayConfigSnapshot &s = m_configs.at(index.row()); + + switch (role) { + case IdRole: { + return s.id; + } + case DisplayNameRole: { + return s.displayName; + } + case CreatedAtRole: { + return s.createdAt.toString("dd.MM.yyyy HH:mm"); + } + } + return QVariant(); +} + +QHash XrayConfigSnapshotsModel::roleNames() const +{ + QHash roles; + roles[IdRole] = "configId"; + roles[DisplayNameRole] = "configName"; + roles[CreatedAtRole] = "configDate"; + return roles; +} + +void XrayConfigSnapshotsModel::reload() +{ + beginResetModel(); + loadAll(); + endResetModel(); +} + +void XrayConfigSnapshotsModel::createFromCurrent(const amnezia::XrayServerConfig &serverConfig) +{ + XrayConfigSnapshot snapshot; + snapshot.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + snapshot.displayName = buildDisplayName(serverConfig); + snapshot.createdAt = QDateTime::currentDateTime(); + snapshot.serverConfig = serverConfig; + + beginInsertRows(QModelIndex(), m_configs.size(), m_configs.size()); + m_configs.append(snapshot); + endInsertRows(); + + persistAll(); +} + +amnezia::XrayServerConfig XrayConfigSnapshotsModel::applyConfig(int index) const +{ + if (index < 0 || index >= m_configs.size()) { + return amnezia::XrayServerConfig {}; + } + + return m_configs.at(index).serverConfig; +} + +void XrayConfigSnapshotsModel::removeConfig(int index) +{ + if (index < 0 || index >= m_configs.size()) { + return; + } + + beginRemoveRows(QModelIndex(), index, index); + m_configs.removeAt(index); + endRemoveRows(); + + persistAll(); + emit configRemoved(index); +} + +QString XrayConfigSnapshotsModel::exportToJson(int index) const +{ + if (index < 0 || index >= m_configs.size()) { + return {}; + } + return QString::fromUtf8(QJsonDocument(m_configs.at(index).toJson()).toJson(QJsonDocument::Indented)); +} + +bool XrayConfigSnapshotsModel::importFromJson(const QString &jsonString) +{ + QJsonDocument doc = QJsonDocument::fromJson(jsonString.toUtf8()); + if (!doc.isObject()) { + emit importFailed(tr("Invalid JSON format")); + return false; + } + + XrayConfigSnapshot snapshot = XrayConfigSnapshot::fromJson(doc.object()); + if (snapshot.id.isEmpty()) { + snapshot.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + if (snapshot.displayName.isEmpty()) { + snapshot.displayName = buildDisplayName(snapshot.serverConfig); + } + snapshot.createdAt = QDateTime::currentDateTime(); + + beginInsertRows(QModelIndex(), m_configs.size(), m_configs.size()); + m_configs.append(snapshot); + endInsertRows(); + + persistAll(); + return true; +} + +QString XrayConfigSnapshotsModel::buildDisplayName(const amnezia::XrayServerConfig &cfg) +{ + // Build a human-readable name: "XHTTP TLS Reality", "RAW Reality", etc. + QString transport; + if (cfg.transport == "xhttp") { + transport = "XHTTP"; + } else if (cfg.transport == "mkcp") { + transport = "mKCP"; + } else { + transport = "RAW (TCP)"; + } + + QString security; + if (cfg.security == "tls") { + security = "TLS"; + } else if (cfg.security == "reality") { + security = "Reality"; + } else { + security = "None"; + } + + return QString("%1 %2").arg(transport, security).trimmed(); +} + +void XrayConfigSnapshotsModel::createFromCurrentModel() +{ + if (!m_xrayConfigModel) { + return; + } + createFromCurrent(m_xrayConfigModel->getProtocolConfig().serverConfig); +} + +void XrayConfigSnapshotsModel::applyConfigToCurrentModel(int index) +{ + if (!m_xrayConfigModel) { + return; + } + amnezia::XrayServerConfig cfg = applyConfig(index); + if (cfg.port.isEmpty()) { + return; // guard against invalid index + } + m_xrayConfigModel->applyServerConfig(cfg); +} diff --git a/client/ui/models/protocols/xrayConfigSnapshotsModel.h b/client/ui/models/protocols/xrayConfigSnapshotsModel.h new file mode 100644 index 000000000..9688cd863 --- /dev/null +++ b/client/ui/models/protocols/xrayConfigSnapshotsModel.h @@ -0,0 +1,76 @@ +#ifndef XRAYCONFIGSMODEL_H +#define XRAYCONFIGSMODEL_H + +#include +#include +#include +#include +#include +#include + +#include "core/models/protocols/xrayProtocolConfig.h" +#include "ui/models/protocols/xrayConfigModel.h" + +class SecureAppSettingsRepository; + +struct XrayConfigSnapshot +{ + QString id; + QString displayName; // auto-generated: "XHTTP TLS Reality", "RAW Reality", etc. + QDateTime createdAt; + amnezia::XrayServerConfig serverConfig; + + QJsonObject toJson() const; + static XrayConfigSnapshot fromJson(const QJsonObject &json); +}; + +class XrayConfigSnapshotsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + IdRole = Qt::UserRole + 1, + DisplayNameRole, + CreatedAtRole, // "dd.MM.yyyy HH:mm" + }; + + explicit XrayConfigSnapshotsModel(SecureAppSettingsRepository *appSettings, XrayConfigModel *xrayConfigModel, + QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + void reload(); + + Q_INVOKABLE void createFromCurrent(const amnezia::XrayServerConfig &serverConfig); + Q_INVOKABLE amnezia::XrayServerConfig applyConfig(int index) const; + Q_INVOKABLE void removeConfig(int index); + + Q_INVOKABLE QString exportToJson(int index) const; + Q_INVOKABLE bool importFromJson(const QString &jsonString); + + // Convenience: create snapshot from live model, apply snapshot back to model + Q_INVOKABLE void createFromCurrentModel(); + Q_INVOKABLE void applyConfigToCurrentModel(int index); + +signals: + void configApplied(int index); + void configRemoved(int index); + void importFailed(const QString &errorMessage); + +protected: + QHash roleNames() const override; + +private: + SecureAppSettingsRepository *m_appSettings; + XrayConfigModel *m_xrayConfigModel; + QVector m_configs; + + void persistAll(); + void loadAll(); + static QString buildDisplayName(const amnezia::XrayServerConfig &cfg); +}; + +#endif // XRAYCONFIGSMODEL_H diff --git a/client/ui/models/protocolsModel.cpp b/client/ui/models/protocolsModel.cpp index 773a92344..b5d07e3c4 100644 --- a/client/ui/models/protocolsModel.cpp +++ b/client/ui/models/protocolsModel.cpp @@ -42,6 +42,8 @@ QHash ProtocolsModel::roleNames() const roles[IsSftpRole] = "isSftp"; roles[IsIpsecRole] = "isIpsec"; roles[IsSocks5ProxyRole] = "isSocks5Proxy"; + roles[IsMtProxyRole] = "isMtProxy"; + roles[IsTelemtRole] = "isTelemt"; return roles; } @@ -71,6 +73,8 @@ 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 IsTelemtRole: return proto == Proto::Telemt; case RawConfigRole: return getRawConfig(); case IsClientProtocolExistsRole: @@ -124,6 +128,8 @@ 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; + case Proto::Telemt: return PageLoader::PageEnum::PageServiceTelemtSettings; default: return PageLoader::PageEnum::PageProtocolOpenVpnSettings; } } diff --git a/client/ui/models/protocolsModel.h b/client/ui/models/protocolsModel.h index a10ab8731..e5ed345a8 100644 --- a/client/ui/models/protocolsModel.h +++ b/client/ui/models/protocolsModel.h @@ -25,7 +25,9 @@ public: IsXrayRole, IsSftpRole, IsIpsecRole, - IsSocks5ProxyRole + IsSocks5ProxyRole, + IsMtProxyRole, + IsTelemtRole, }; 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/services/telemtConfigModel.cpp b/client/ui/models/services/telemtConfigModel.cpp new file mode 100644 index 000000000..6a3fd9eb1 --- /dev/null +++ b/client/ui/models/services/telemtConfigModel.cpp @@ -0,0 +1,406 @@ +#include "telemtConfigModel.h" + +#include + +#include "core/utils/qrCodeUtils.h" +#include "core/utils/constants/configKeys.h" +#include "core/utils/constants/protocolConstants.h" +#include "qrcodegen.hpp" + +using namespace amnezia; + +TelemtConfigModel::TelemtConfigModel(QObject *parent) : QAbstractListModel(parent) {} + +void TelemtConfigModel::applyDefaults(TelemtProtocolConfig &c) { + if (c.port.isEmpty()) { + c.port = QString::fromUtf8(protocols::telemt::defaultPort); + } + if (c.transportMode.isEmpty()) { + c.transportMode = QString::fromUtf8(protocols::telemt::transportModeStandard); + } + if (c.workersMode.isEmpty()) { + c.workersMode = QString::fromUtf8(protocols::telemt::workersModeAuto); + } + if (c.workers.isEmpty()) { + c.workers = QString::fromUtf8(protocols::telemt::defaultWorkers); + } + if (c.userName.isEmpty()) { + c.userName = QString::fromUtf8(protocols::telemt::defaultUserName); + } +} + +int TelemtConfigModel::rowCount(const QModelIndex &parent) const { + Q_UNUSED(parent); + return 1; +} + +bool TelemtConfigModel::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: { + m_protocolConfig.tag = value.toString(); + break; + } + case Roles::IsEnabledRole: { + m_protocolConfig.isEnabled = value.toBool(); + break; + } + case Roles::PublicHostRole: { + m_protocolConfig.publicHost = value.toString(); + break; + } + case Roles::TransportModeRole: { + m_protocolConfig.transportMode = value.toString(); + break; + } + case Roles::TlsDomainRole: { + m_protocolConfig.tlsDomain = value.toString(); + 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: { + m_protocolConfig.natInternalIp = value.toString(); + break; + } + case Roles::NatExternalIpRole: { + m_protocolConfig.natExternalIp = value.toString(); + break; + } + case Roles::MaskEnabledRole: { + m_protocolConfig.maskEnabled = value.toBool(); + break; + } + case Roles::UseMiddleProxyRole: { + m_protocolConfig.useMiddleProxy = value.toBool(); + break; + } + case Roles::TlsEmulationRole: { + m_protocolConfig.tlsEmulation = value.toBool(); + break; + } + case Roles::UserNameRole: { + m_protocolConfig.userName = value.toString(); + break; + } + default: { + return false; + } + } + + emit dataChanged(index, index, QList{role}); + return true; +} + +QVariant TelemtConfigModel::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::fromUtf8(protocols::telemt::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(QString(configKey::hostName)).toString() + : m_protocolConfig.publicHost; + } + case Roles::TransportModeRole: { + return m_protocolConfig.transportMode.isEmpty() ? QString::fromUtf8( + protocols::telemt::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::fromUtf8(protocols::telemt::workersModeAuto) + : m_protocolConfig.workersMode; + } + case Roles::WorkersRole: { + return m_protocolConfig.workers.isEmpty() ? QString::fromUtf8(protocols::telemt::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; + } + case Roles::MaskEnabledRole: { + return m_protocolConfig.maskEnabled; + } + case Roles::UseMiddleProxyRole: { + return m_protocolConfig.useMiddleProxy; + } + case Roles::TlsEmulationRole: { + return m_protocolConfig.tlsEmulation; + } + case Roles::UserNameRole: { + return m_protocolConfig.userName.isEmpty() ? QString::fromUtf8(protocols::telemt::defaultUserName) + : m_protocolConfig.userName; + } + } + + return QVariant(); +} + +void TelemtConfigModel::updateModel(DockerContainer container, const TelemtProtocolConfig &protocolConfig) { + beginResetModel(); + m_container = container; + m_protocolConfig = protocolConfig; + applyDefaults(m_protocolConfig); + endResetModel(); +} + +void TelemtConfigModel::updateModel(const QJsonObject &config) { + beginResetModel(); + + m_fullConfig = config; + m_protocolConfig = TelemtProtocolConfig::fromJson(config.value(QString(configKey::telemt)).toObject()); + applyDefaults(m_protocolConfig); + + endResetModel(); +} + +QJsonObject TelemtConfigModel::getConfig() { + m_fullConfig.insert(QString(configKey::telemt), m_protocolConfig.toJson()); + return m_fullConfig; +} + +TelemtProtocolConfig TelemtConfigModel::getProtocolConfig() { + return m_protocolConfig; +} + +void TelemtConfigModel::generateSecret() { + 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 TelemtConfigModel::setSecret(const QString &secret) { + if (secret.isEmpty()) { + return; + } + setData(index(0), secret, SecretRole); +} + +bool TelemtConfigModel::validateAndSetSecret(const QString &rawSecret) { + if (!QRegularExpression(QStringLiteral("^[0-9a-fA-F]{32}$")).match(rawSecret).hasMatch()) { + return false; + } + setData(index(0), rawSecret, SecretRole); + return true; +} + +void TelemtConfigModel::setPort(const QString &port) { + setData(index(0), port, PortRole); +} + +void TelemtConfigModel::setTag(const QString &tag) { + setData(index(0), tag, TagRole); +} + +void TelemtConfigModel::setPublicHost(const QString &host) { + setData(index(0), host, PublicHostRole); +} + +void TelemtConfigModel::setTransportMode(const QString &mode) { + setData(index(0), mode, TransportModeRole); +} + +QString TelemtConfigModel::getTransportMode() const { + return m_protocolConfig.transportMode.isEmpty() ? QString::fromUtf8(protocols::telemt::transportModeStandard) + : m_protocolConfig.transportMode; +} + +QString TelemtConfigModel::getTlsDomain() const { + return m_protocolConfig.tlsDomain.isEmpty() ? QString::fromUtf8(protocols::telemt::defaultTlsDomain) + : m_protocolConfig.tlsDomain; +} + +QString TelemtConfigModel::getPublicHost() const { + return m_protocolConfig.publicHost; +} + +void TelemtConfigModel::setTlsDomain(const QString &domain) { + setData(index(0), domain, TlsDomainRole); +} + +void TelemtConfigModel::setWorkersMode(const QString &mode) { + setData(index(0), mode, WorkersModeRole); +} + +void TelemtConfigModel::setWorkers(const QString &workers) { + setData(index(0), workers, WorkersRole); +} + +void TelemtConfigModel::setNatEnabled(bool enabled) { + setData(index(0), enabled, NatEnabledRole); +} + +void TelemtConfigModel::setNatInternalIp(const QString &ip) { + setData(index(0), ip, NatInternalIpRole); +} + +void TelemtConfigModel::setNatExternalIp(const QString &ip) { + setData(index(0), ip, NatExternalIpRole); +} + +void TelemtConfigModel::setMaskEnabled(bool enabled) { + setData(index(0), enabled, MaskEnabledRole); +} + +void TelemtConfigModel::setUseMiddleProxy(bool enabled) { + setData(index(0), enabled, UseMiddleProxyRole); +} + +void TelemtConfigModel::setTlsEmulation(bool enabled) { + setData(index(0), enabled, TlsEmulationRole); +} + +void TelemtConfigModel::setUserName(const QString &name) { + setData(index(0), name, UserNameRole); +} + +void TelemtConfigModel::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 TelemtConfigModel::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}); +} + +void TelemtConfigModel::setEnabled(bool enabled) { + m_protocolConfig.isEnabled = enabled; + emit dataChanged(index(0), index(0), QList{IsEnabledRole}); +} + +QString TelemtConfigModel::generateQrCode(const QString &text) { + if (text.isEmpty()) { + return ""; + } + auto qr = qrCodeUtils::generateQrCode(text.toUtf8()); + return qrCodeUtils::svgToBase64(QString::fromStdString(toSvgString(qr, 1))); +} + +QString TelemtConfigModel::defaultTlsDomain() const { + return QString::fromUtf8(protocols::telemt::defaultTlsDomain); +} + +QString TelemtConfigModel::defaultPort() const { + return QString::fromUtf8(protocols::telemt::defaultPort); +} + +QString TelemtConfigModel::defaultWorkers() const { + return QString::fromUtf8(protocols::telemt::defaultWorkers); +} + +int TelemtConfigModel::maxWorkers() const { + return protocols::telemt::maxWorkers; +} + +QString TelemtConfigModel::transportModeStandard() const { + return QString::fromUtf8(protocols::telemt::transportModeStandard); +} + +QString TelemtConfigModel::transportModeFakeTLS() const { + return QString::fromUtf8(protocols::telemt::transportModeFakeTLS); +} + +QString TelemtConfigModel::workersModeAuto() const { + return QString::fromUtf8(protocols::telemt::workersModeAuto); +} + +QString TelemtConfigModel::workersModeManual() const { + return QString::fromUtf8(protocols::telemt::workersModeManual); +} + +QHash TelemtConfigModel::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"; + roles[MaskEnabledRole] = "maskEnabled"; + roles[UseMiddleProxyRole] = "useMiddleProxy"; + roles[TlsEmulationRole] = "tlsEmulation"; + roles[UserNameRole] = "userName"; + + return roles; +} diff --git a/client/ui/models/services/telemtConfigModel.h b/client/ui/models/services/telemtConfigModel.h new file mode 100644 index 000000000..c386d210e --- /dev/null +++ b/client/ui/models/services/telemtConfigModel.h @@ -0,0 +1,130 @@ +#ifndef TELEMTCONFIGMODEL_H +#define TELEMTCONFIGMODEL_H + +#include +#include +#include + +#include "core/models/protocols/telemtProtocolConfig.h" +#include "core/utils/containerEnum.h" + +class TelemtConfigModel : 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, + MaskEnabledRole, + UseMiddleProxyRole, + TlsEmulationRole, + UserNameRole + }; + + explicit TelemtConfigModel(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::TelemtProtocolConfig &protocolConfig); + + void updateModel(const QJsonObject &config); + + QJsonObject getConfig(); + + amnezia::TelemtProtocolConfig 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); + + Q_INVOKABLE QString generateQrCode(const QString &text); + + Q_INVOKABLE void setEnabled(bool enabled); + + Q_INVOKABLE void setMaskEnabled(bool enabled); + + Q_INVOKABLE void setUseMiddleProxy(bool enabled); + + Q_INVOKABLE void setTlsEmulation(bool enabled); + + Q_INVOKABLE void setUserName(const QString &name); + + 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; + +protected: + QHash roleNames() const override; + +private: + static void applyDefaults(amnezia::TelemtProtocolConfig &c); + + amnezia::DockerContainer m_container = amnezia::DockerContainer::None; + QJsonObject m_fullConfig; + amnezia::TelemtProtocolConfig m_protocolConfig; +}; + +#endif // TELEMTCONFIGMODEL_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 209c56438..9e51ae8d8 100644 --- a/client/ui/qml/Components/SettingsContainersListView.qml +++ b/client/ui/qml/Components/SettingsContainersListView.qml @@ -45,6 +45,12 @@ ListViewType { PageController.goToPage(PageEnum.PageProtocolRaw) } else if (isDns) { PageController.goToPage(PageEnum.PageServiceDnsSettings) + } else if (isMtProxy) { + MtProxyConfigModel.updateModel(config) + PageController.goToPage(PageEnum.PageServiceMtProxySettings) + } else if (isTelemt) { + TelemtConfigModel.updateModel(config) + PageController.goToPage(PageEnum.PageServiceTelemtSettings) } else { InstallController.updateProtocols(ServersUiController.getServerId(ServersUiController.processedServerIndex), 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/Controls2/MinMaxRowType.qml b/client/ui/qml/Controls2/MinMaxRowType.qml new file mode 100644 index 000000000..90a7fae7b --- /dev/null +++ b/client/ui/qml/Controls2/MinMaxRowType.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" + +// MinMaxRowType — two side-by-side labeled text fields: Min / Max +// Usage: +// MinMaxRowType { +// minValue: "0" +// maxValue: "0" +// onMinChanged: someProperty = val +// onMaxChanged: someProperty = val +// } +Item { + id: root + + property string minValue: "0" + property string maxValue: "0" + + signal minChanged(string val) + signal maxChanged(string val) + + implicitHeight: row.implicitHeight + implicitWidth: row.implicitWidth + + RowLayout { + id: row + anchors.fill: parent + spacing: 10 + + // Min field + TextFieldWithHeaderType { + Layout.fillWidth: true + headerText: qsTr("Min") + textField.text: root.minValue + textField.validator: IntValidator { bottom: 0 } + textField.onEditingFinished: { + if (textField.text !== root.minValue) { + root.minChanged(textField.text) + } + } + } + + // Max field + TextFieldWithHeaderType { + Layout.fillWidth: true + headerText: qsTr("Max") + textField.text: root.maxValue + textField.validator: IntValidator { bottom: 0 } + textField.onEditingFinished: { + if (textField.text !== root.maxValue) { + root.maxChanged(textField.text) + } + } + } + } +} diff --git a/client/ui/qml/Controls2/TextFieldWithHeaderType.qml b/client/ui/qml/Controls2/TextFieldWithHeaderType.qml index 39be4b97b..0387ac8cb 100644 --- a/client/ui/qml/Controls2/TextFieldWithHeaderType.qml +++ b/client/ui/qml/Controls2/TextFieldWithHeaderType.qml @@ -10,6 +10,7 @@ Item { id: root property string headerText + property string subtitleText // optional line under header (e.g. default value hint) property string headerTextDisabledColor: AmneziaStyle.color.charcoalGray property string headerTextColor: AmneziaStyle.color.mutedGray @@ -84,6 +85,15 @@ Item { Layout.fillWidth: true } + SmallTextType { + text: root.subtitleText + visible: root.subtitleText !== "" + color: AmneziaStyle.color.charcoalGray + font.pixelSize: 13 + Layout.fillWidth: true + Layout.topMargin: visible ? 2 : 0 + } + TextField { id: textField diff --git a/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml new file mode 100644 index 000000000..afde16088 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayFlowSettings.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Flow") + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("Empty") + checked: flow === "" + onClicked: flow = "" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: "xtls-rprx-vision" + checked: flow === "xtls-rprx-vision" + onClicked: flow = "xtls-rprx-vision" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: "xtls-rprx-vision-udp443" + checked: flow === "xtls-rprx-vision-udp443" + onClicked: flow = "xtls-rprx-vision-udp443" + } + + DividerType { + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml b/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml new file mode 100644 index 000000000..fc1a58de1 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXraySecuritySettings.qml @@ -0,0 +1,292 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Security") + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("None") + checked: security === "none" + onClicked: security = "none" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("TLS") + checked: security === "tls" + onClicked: security = "tls" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("Reality") + checked: security === "reality" + onClicked: security = "reality" + } + + DividerType { + } + + // ── TLS fields ──────────────────────────────────────────── + ColumnLayout { + visible: security === "tls" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: tlsAlpnDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: alpn + descriptionText: qsTr("ALPN") + headerText: qsTr("ALPN") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.alpnOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + alpn = selectedText + tlsAlpnDropDown.text = selectedText + tlsAlpnDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === alpn) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + tlsAlpnDropDown.text = alpn + } + } + } + + DropDownType { + id: tlsFingerprintDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: fingerprint + descriptionText: qsTr("Fingerprint") + headerText: qsTr("Fingerprint") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.fingerprintOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + fingerprint = selectedText + tlsFingerprintDropDown.text = selectedText + tlsFingerprintDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === fingerprint) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + tlsFingerprintDropDown.text = fingerprint + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Server Name (SNI)") + textField.text: sni + textField.onEditingFinished: { + if (textField.text !== sni) sni = textField.text + } + } + } + + // ── Reality fields ──────────────────────────────────────── + ColumnLayout { + visible: security === "reality" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: realityFingerprintDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: fingerprint + descriptionText: qsTr("Fingerprint") + headerText: qsTr("Fingerprint") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.fingerprintOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + fingerprint = selectedText + realityFingerprintDropDown.text = selectedText + realityFingerprintDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === fingerprint) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + realityFingerprintDropDown.text = fingerprint + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Server Name (SNI)") + textField.text: sni + textField.onEditingFinished: { + if (textField.text !== sni) sni = textField.text + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXraySettings.qml b/client/ui/qml/Pages2/PageProtocolXraySettings.qml index 43c57caff..c63d58eca 100644 --- a/client/ui/qml/Pages2/PageProtocolXraySettings.qml +++ b/client/ui/qml/Pages2/PageProtocolXraySettings.qml @@ -17,6 +17,20 @@ import "../Components" PageType { id: root + function formatTransport(value) { + if (value === "raw") return "RAW (TCP)" + if (value === "xhttp") return "XHTTP" + if (value === "mkcp") return "mKCP" + return value + } + + function formatSecurity(value) { + if (value === "none") return "None" + if (value === "tls") return "TLS" + if (value === "reality") return "Reality" + return value + } + BackButtonType { id: backButton @@ -50,88 +64,125 @@ PageType { spacing: 0 - BaseHeaderType { + RowLayout { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - headerText: qsTr("XRay settings") + Layout.topMargin: 0 + + BaseHeaderType { + Layout.fillWidth: true + headerText: qsTr("XRay VLESS settings") + } + + ImageButtonType { + Layout.alignment: Qt.AlignTop | Qt.AlignRight + implicitWidth: 40 + implicitHeight: 40 + image: "qrc:/images/controls/more-vertical.svg" + imageColor: AmneziaStyle.color.paleGray + onClicked: PageController.goToPage(PageEnum.PageProtocolXraySnapshots) + } + } + + LabelTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 4 + text: qsTr("More about settings") + color: AmneziaStyle.color.goldenApricot + font.pixelSize: 16 + lineHeight: 24 + LanguageUiController.getLineHeightAppend() + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally("https://docs.amnezia.org") + } } TextFieldWithHeaderType { id: textFieldWithHeaderType - Layout.fillWidth: true Layout.topMargin: 32 Layout.leftMargin: 16 Layout.rightMargin: 16 - enabled: listView.enabled - - headerText: qsTr("Disguised as traffic from") - textField.text: site - - textField.onEditingFinished: { - if (textField.text !== site) { - var tmpText = textField.text - tmpText = tmpText.toLocaleLowerCase() - - if (tmpText.startsWith("https://")) { - tmpText = textField.text.substring(8) - site = tmpText - } else { - site = textField.text - } - } - } - - checkEmptyText: true - } - - TextFieldWithHeaderType { - id: portTextField - - Layout.fillWidth: true - Layout.topMargin: 16 - Layout.leftMargin: 16 - Layout.rightMargin: 16 - - enabled: listView.enabled - headerText: qsTr("Port") textField.text: port textField.maximumLength: 5 - textField.validator: IntValidator { bottom: 1; top: 65535 } - - textField.onEditingFinished: { - if (textField.text !== port) { - port = textField.text - } + textField.validator: IntValidator { + bottom: 1; top: 65535 + } + textField.onEditingFinished: { + if (textField.text !== port) port = textField.text } - checkEmptyText: true } + LabelWithButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + text: qsTr("Transport") + descriptionText: root.formatTransport(transport) + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXrayTransportSettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Security") + descriptionText: root.formatSecurity(security) + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXraySecuritySettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Flow") + descriptionText: flow + rightImageSource: "qrc:/images/controls/chevron-right.svg" + enabled: listView.enabled + clickedFunction: function() { + PageController.goToPage(PageEnum.PageProtocolXrayFlowSettings) + } + } + + DividerType { + } + + Item { + Layout.fillWidth: true; Layout.preferredHeight: 24 + } + BasicButtonType { id: saveButton - Layout.fillWidth: true - Layout.topMargin: 24 - Layout.bottomMargin: 24 + Layout.bottomMargin: 8 Layout.leftMargin: 16 Layout.rightMargin: 16 - - enabled: portTextField.errorText === "" - + // Show Save immediately while user edits port, even before focus loss. + visible: listView.enabled && (XrayConfigModel.hasUnsavedChanges || textFieldWithHeaderType.textField.text !== port) + enabled: visible && textFieldWithHeaderType.errorText === "" text: qsTr("Save") - onClicked: function() { forceActiveFocus() - var headerText = qsTr("Save settings?") var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") var yesButtonText = qsTr("Continue") var noButtonText = qsTr("Cancel") - var yesButtonFunction = function() { if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) @@ -142,16 +193,32 @@ PageType { InstallController.updateContainer(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex, ProtocolEnum.Xray) } var noButtonFunction = function() { - if (!GC.isMobile()) { - saveButton.forceActiveFocus() - } + if (!GC.isMobile()) saveButton.forceActiveFocus() } showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) } - Keys.onEnterPressed: saveButton.clicked() Keys.onReturnPressed: saveButton.clicked() } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Reset settings") + textColor: AmneziaStyle.color.vibrantRed + visible: listView.enabled + clickedFunction: function() { + var yesButtonFunction = function() { + XrayConfigModel.resetToDefaults() + } + showQuestionDrawer(qsTr("Reset settings?"), qsTr("All XRay settings will be restored to defaults."), + qsTr("Reset"), qsTr("Cancel"), yesButtonFunction, function() { + }) + } + } + + Item { + Layout.fillWidth: true; Layout.preferredHeight: 32 + } } } } diff --git a/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml b/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml new file mode 100644 index 000000000..446ad468a --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXraySnapshots.qml @@ -0,0 +1,291 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" +import Qt.labs.platform 1.1 + +PageType { + id: root + + property string selectedConfigName: "" + property int selectedConfigIndex: -1 + + // Reload the list every time we open this page + Component.onCompleted: XrayConfigSnapshotsModel.reload() + + // ── Save xray config snapshot to file ──────────────────────────── + function saveConfigToFile(json) { + var fileName = "" + if (GC.isMobile()) { + fileName = "amnezia_xray_config.json" + } else { + fileName = SystemController.getFileName( + qsTr("Save XRay configuration"), + qsTr("JSON files (*.json)"), + StandardPaths.standardLocations(StandardPaths.DocumentsLocation) + "/amnezia_xray_config", + true, + ".json") + } + if (fileName !== "") { + PageController.showBusyIndicator(true) + ExportController.setConfigFromString(json, fileName) + PageController.showBusyIndicator(false) + PageController.showNotificationMessage(qsTr("Configuration saved")) + } + } + + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + model: XrayConfigSnapshotsModel + + header: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("XRay Configurations") + } + + // ── Create from current settings ────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Create configuration based on current settings") + textMaximumLineCount: 2 + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + XrayConfigSnapshotsModel.createFromCurrentModel() + } + } + + DividerType { + } + + // ── Export ──────────────────────────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Export settings") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + var idx = root.selectedConfigIndex >= 0 ? root.selectedConfigIndex : 0 + if (listView.count > 0) { + var json = XrayConfigSnapshotsModel.exportToJson(idx) + saveConfigToFile(json) + } + } + } + + DividerType { + } + + // ── Import ──────────────────────────────────────────────── + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Import settings") + descriptionText: qsTr("In JSON format") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + var filePath = SystemController.getFileName( + qsTr("Open XRay configuration"), + qsTr("JSON files (*.json)")) + if (filePath !== "") { + var jsonContent = ImportController.readTextFile(filePath) + if (jsonContent !== "") { + if (!XrayConfigSnapshotsModel.importFromJson(jsonContent)) { + PageController.showNotificationMessage(qsTr("Failed to import configuration")) + } else { + PageController.showNotificationMessage(qsTr("Configuration imported successfully")) + } + } + } + } + } + + DividerType { + } + + // ── Section label ───────────────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Configurations") + color: AmneziaStyle.color.mutedGray + visible: listView.count > 0 + } + } + + // ── Empty state ─────────────────────────────────────────────── + footer: ColumnLayout { + width: listView.width + visible: listView.count === 0 + spacing: 0 + + Item { + Layout.preferredHeight: 32 + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("No saved configurations yet.\nCreate one from the current settings.") + color: AmneziaStyle.color.mutedGray + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + + // ── Config list items ───────────────────────────────────────── + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + LabelWithButtonType { + Layout.fillWidth: true + text: configName + descriptionText: configDate + rightImageSource: "qrc:/images/controls/more-vertical.svg" + clickedFunction: function () { + root.selectedConfigName = configName + root.selectedConfigIndex = index + configActionsDrawer.openTriggered() + } + } + + DividerType { + } + } + } + + // ── Import result handler ───────────────────────────────────────── + Connections { + target: XrayConfigSnapshotsModel + + function onImportFailed(errorMessage) { + PageController.showNotificationMessage(errorMessage) + } + } + + // ── Per-config actions drawer ───────────────────────────────────── + DrawerType2 { + id: configActionsDrawer + parent: root + anchors.fill: parent + expandedHeight: root.height * 0.35 + + expandedStateContent: ColumnLayout { + id: drawerContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + spacing: 0 + + onImplicitHeightChanged: { + configActionsDrawer.expandedHeight = drawerContent.implicitHeight + 32 + } + + BackButtonType { + Layout.fillWidth: true + Layout.topMargin: 16 + backButtonFunction: function () { + configActionsDrawer.closeTriggered() + } + } + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 16 + headerText: root.selectedConfigName + } + + // Apply + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Apply configuration") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + configActionsDrawer.closeTriggered() + XrayConfigSnapshotsModel.applyConfigToCurrentModel(root.selectedConfigIndex) + PageController.closePage() + } + } + + DividerType { + } + + // Export this config + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Export configuration") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + configActionsDrawer.closeTriggered() + var json = XrayConfigSnapshotsModel.exportToJson(root.selectedConfigIndex) + saveConfigToFile(json) + } + } + + DividerType { + } + + // Delete + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("Delete configuration") + textColor: AmneziaStyle.color.vibrantRed + clickedFunction: function () { + configActionsDrawer.closeTriggered() + var yesButtonFunction = function () { + XrayConfigSnapshotsModel.removeConfig(root.selectedConfigIndex) + root.selectedConfigIndex = -1 + root.selectedConfigName = "" + } + showQuestionDrawer( + qsTr("Delete configuration?"), + qsTr("This action cannot be undone."), + qsTr("Delete"), qsTr("Cancel"), + yesButtonFunction, function () { + }) + } + } + + DividerType { + } + Item { + Layout.preferredHeight: 16 + } + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml new file mode 100644 index 000000000..bbda7eb87 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayTransportSettings.qml @@ -0,0 +1,755 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("Transport") + } + + // ── Radio buttons ───────────────────────────────────────── + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("RAW (TCP)") + checked: transport === "raw" + onToggled: if (checked && transport !== "raw") transport = "raw" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("XHTTP") + descriptionText: qsTr("Advanced users") + checked: transport === "xhttp" + onToggled: if (checked && transport !== "xhttp") transport = "xhttp" + } + + DividerType { + } + + VerticalRadioButton { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("mKCP") + checked: transport === "mkcp" + onToggled: if (checked && transport !== "mkcp") transport = "mkcp" + } + + DividerType { + } + + // ══════════════════════════════════════════════════════════ + // mKCP Settings + // ══════════════════════════════════════════════════════════ + ColumnLayout { + visible: transport === "mkcp" + Layout.fillWidth: true + spacing: 0 + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("mKCP Settings") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("TTI") + subtitleText: qsTr("Default: %1 ms", "mKCP TTI").arg(XrayConfigModel.mkcpDefaultTti()) + textField.text: mkcpTti + textField.onEditingFinished: { + if (textField.text !== mkcpTti) mkcpTti = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("uplinkCapacity") + subtitleText: qsTr("Default: %1 Mbit/s", "mKCP uplink").arg(XrayConfigModel.mkcpDefaultUplinkCapacity()) + textField.text: mkcpUplinkCapacity + textField.onEditingFinished: { + if (textField.text !== mkcpUplinkCapacity) mkcpUplinkCapacity = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("downlinkCapacity") + subtitleText: qsTr("Default: %1 Mbit/s", "mKCP downlink").arg(XrayConfigModel.mkcpDefaultDownlinkCapacity()) + textField.text: mkcpDownlinkCapacity + textField.onEditingFinished: { + if (textField.text !== mkcpDownlinkCapacity) mkcpDownlinkCapacity = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("readBufferSize") + subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultReadBufferSize()) + textField.text: mkcpReadBufferSize + textField.onEditingFinished: { + if (textField.text !== mkcpReadBufferSize) mkcpReadBufferSize = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("writeBufferSize") + subtitleText: qsTr("Default: %1 MiB").arg(XrayConfigModel.mkcpDefaultWriteBufferSize()) + textField.text: mkcpWriteBufferSize + textField.onEditingFinished: { + if (textField.text !== mkcpWriteBufferSize) mkcpWriteBufferSize = textField.text + } + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + Layout.topMargin: 8 + text: qsTr("Congestion") + checked: mkcpCongestion + onToggled: mkcpCongestion = checked + } + } + + // ══════════════════════════════════════════════════════════ + // XHTTP Settings + // ══════════════════════════════════════════════════════════ + ColumnLayout { + visible: transport === "xhttp" + Layout.fillWidth: true + spacing: 0 + + DropDownType { + id: modeDropDown + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpMode + descriptionText: qsTr("Mode") + headerText: qsTr("Mode") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpModeOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpMode = selectedText + modeDropDown.text = selectedText + modeDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpMode) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + modeDropDown.text = xhttpMode + } + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("HTTP Profile") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Host") + textField.text: xhttpHost + textField.onEditingFinished: { + if (textField.text !== xhttpHost) xhttpHost = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("Path") + textField.text: xhttpPath + textField.onEditingFinished: { + if (textField.text !== xhttpPath) xhttpPath = textField.text + } + } + + DropDownType { + id: headersDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpHeadersTemplate + descriptionText: qsTr("Headers template") + headerText: qsTr("Headers template") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpHeadersTemplateOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpHeadersTemplate = selectedText + headersDropDown.text = selectedText + headersDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpHeadersTemplate) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + headersDropDown.text = xhttpHeadersTemplate + } + } + } + + DropDownType { + id: uplinkMethodDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpUplinkMethod + descriptionText: qsTr("UplinkHTTPMethod") + headerText: qsTr("UplinkHTTPMethod") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpUplinkMethodOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpUplinkMethod = selectedText + uplinkMethodDropDown.text = selectedText + uplinkMethodDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpUplinkMethod) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + uplinkMethodDropDown.text = xhttpUplinkMethod + } + } + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + Layout.topMargin: 16 + text: qsTr("Disable gRPC Header") + descriptionText: qsTr("noGRPCHeader") + checked: xhttpDisableGrpc + onToggled: xhttpDisableGrpc = checked + } + + DividerType { + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("Disable SSE Header") + descriptionText: qsTr("noSSEHeader") + checked: xhttpDisableSse + onToggled: xhttpDisableSse = checked + } + + DividerType { + } + + // ── Session & Sequence ──────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Session & Sequence") + color: AmneziaStyle.color.mutedGray + } + + DropDownType { + id: sessionPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSessionPlacement + descriptionText: qsTr("SessionPlacement") + headerText: qsTr("SessionPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSessionPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSessionPlacement = selectedText + sessionPlacementDropDown.text = selectedText + sessionPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSessionPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + sessionPlacementDropDown.text = xhttpSessionPlacement + } + } + } + + DropDownType { + id: sessionKeyDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSessionKey + descriptionText: qsTr("SessionKey") + headerText: qsTr("SessionKey") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSessionKeyOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSessionKey = selectedText + sessionKeyDropDown.text = selectedText + sessionKeyDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSessionKey) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + sessionKeyDropDown.text = xhttpSessionKey + } + } + } + + DropDownType { + id: seqPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpSeqPlacement + descriptionText: qsTr("SeqPlacement") + headerText: qsTr("SeqPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpSeqPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpSeqPlacement = selectedText + seqPlacementDropDown.text = selectedText + seqPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpSeqPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + seqPlacementDropDown.text = xhttpSeqPlacement + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("SeqKey") + textField.text: xhttpSeqKey + textField.onEditingFinished: { + if (textField.text !== xhttpSeqKey) xhttpSeqKey = textField.text + } + } + + DropDownType { + id: uplinkDataPlacementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xhttpUplinkDataPlacement + descriptionText: qsTr("UplinkDataPlacement") + headerText: qsTr("UplinkDataPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xhttpUplinkDataPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xhttpUplinkDataPlacement = selectedText + uplinkDataPlacementDropDown.text = selectedText + uplinkDataPlacementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xhttpUplinkDataPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + uplinkDataPlacementDropDown.text = xhttpUplinkDataPlacement + } + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("UplinkDataKey") + textField.text: xhttpUplinkDataKey + textField.onEditingFinished: { + if (textField.text !== xhttpUplinkDataKey) xhttpUplinkDataKey = textField.text + } + } + + // ── Traffic Shaping ─────────────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Traffic Shaping") + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("UplinkChunkSize") + textField.text: xhttpUplinkChunkSize + textField.validator: IntValidator { + bottom: 0 + } + textField.onEditingFinished: { + if (textField.text !== xhttpUplinkChunkSize) xhttpUplinkChunkSize = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("scMaxBufferedPosts") + textField.text: xhttpScMaxBufferedPosts + textField.onEditingFinished: { + if (textField.text !== xhttpScMaxBufferedPosts) xhttpScMaxBufferedPosts = textField.text + } + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scMaxEachPostBytes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScMaxEachPostBytesMin + maxValue: xhttpScMaxEachPostBytesMax + onMinChanged: xhttpScMaxEachPostBytesMin = val + onMaxChanged: xhttpScMaxEachPostBytesMax = val + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scStreamUpServerSecs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScStreamUpServerSecsMin + maxValue: xhttpScStreamUpServerSecsMax + onMinChanged: xhttpScStreamUpServerSecsMin = val + onMaxChanged: xhttpScStreamUpServerSecsMax = val + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("scMinPostsIntervalMs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xhttpScMinPostsIntervalMsMin + maxValue: xhttpScMinPostsIntervalMsMax + onMinChanged: xhttpScMinPostsIntervalMsMin = val + onMaxChanged: xhttpScMinPostsIntervalMsMax = val + } + + // ── Padding and multiplexing ────────────────────────── + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("Padding and multiplexing") + color: AmneziaStyle.color.mutedGray + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("xPadding") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXPaddingSettings) + } + } + + DividerType { + } + + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("XMux") + descriptionText: xmuxEnabled ? qsTr("On") : qsTr("Off") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXmuxSettings) + } + } + + DividerType { + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml new file mode 100644 index 000000000..026c8cfa7 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXPaddingBytesSettings.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xPaddingBytes") + } + + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("Range") + color: AmneziaStyle.color.mutedGray + } + + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xPaddingBytesMin + maxValue: xPaddingBytesMax + onMinChanged: xPaddingBytesMin = val + onMaxChanged: xPaddingBytesMax = val + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml new file mode 100644 index 000000000..b06b745f2 --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXPaddingSettings.qml @@ -0,0 +1,224 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xPadding") + } + + // xPaddingBytes — min/max display row + LabelWithButtonType { + Layout.fillWidth: true + text: qsTr("xPaddingBytes") + descriptionText: (xPaddingBytesMin !== "" ? xPaddingBytesMin : "0") + "—" + (xPaddingBytesMax !== "" ? xPaddingBytesMax : "0") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + clickedFunction: function () { + PageController.goToPage(PageEnum.PageProtocolXrayXPaddingBytesSettings) + } + } + + DividerType { + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("xPaddingObfsMode") + checked: xPaddingObfsMode + onToggled: xPaddingObfsMode = checked + } + + DividerType { + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + headerText: qsTr("xPaddingKey") + textField.text: xPaddingKey + textField.onEditingFinished: { + if (textField.text !== xPaddingKey) xPaddingKey = textField.text + } + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + headerText: qsTr("xPaddingHeader") + textField.text: xPaddingHeader + textField.onEditingFinished: { + if (textField.text !== xPaddingHeader) xPaddingHeader = textField.text + } + } + + DropDownType { + id: placementDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xPaddingPlacement + descriptionText: qsTr("xPaddingPlacement") + headerText: qsTr("xPaddingPlacement") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xPaddingPlacementOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xPaddingPlacement = selectedText + placementDropDown.text = selectedText + placementDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xPaddingPlacement) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + placementDropDown.text = xPaddingPlacement + } + } + } + + DropDownType { + id: methodDropDown + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: xPaddingMethod + descriptionText: qsTr("xPaddingMethod") + headerText: qsTr("xPaddingMethod") + drawerParent: root + listView: ListViewWithRadioButtonType { + rootWidth: root.width + model: ListModel { + Component.onCompleted: { + var opts = XrayConfigModel.xPaddingMethodOptions() + for (var i = 0; i < opts.length; i++) { + append({name: opts[i]}) + } + } + } + clickedFunction: function () { + xPaddingMethod = selectedText + methodDropDown.text = selectedText + methodDropDown.closeTriggered() + } + Component.onCompleted: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).name === xPaddingMethod) { + selectedIndex = i; + break + } + } + } + } + Connections { + target: XrayConfigModel + + function onDataChanged() { + methodDropDown.text = xPaddingMethod + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} diff --git a/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml b/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml new file mode 100644 index 000000000..dff46b2dd --- /dev/null +++ b/client/ui/qml/Pages2/PageProtocolXrayXmuxSettings.qml @@ -0,0 +1,222 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import ProtocolEnum 1.0 +import Style 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + BackButtonType { + id: backButton + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 20 + PageController.safeAreaTopMargin + } + + ListViewType { + id: listView + anchors.top: backButton.bottom + anchors.bottom: saveButton.visible ? saveButton.top : parent.bottom + anchors.left: parent.left + anchors.right: parent.right + + enabled: ServersUiController.isProcessedServerHasWriteAccess() + model: XrayConfigModel + + delegate: ColumnLayout { + width: listView.width + spacing: 0 + + BaseHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 0 + Layout.bottomMargin: 24 + headerText: qsTr("xmux") + } + + SwitcherType { + Layout.fillWidth: true + Layout.margins: 16 + text: qsTr("xmux") + checked: xmuxEnabled + onToggled: xmuxEnabled = checked + } + + DividerType { + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + enabled: xmuxEnabled + + // maxConcurrency + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.bottomMargin: 8 + text: qsTr("maxConcurrency") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxMaxConcurrencyMin + maxValue: xmuxMaxConcurrencyMax + onMinChanged: xmuxMaxConcurrencyMin = val + onMaxChanged: xmuxMaxConcurrencyMax = val + } + + // maxConnections + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("maxConnections") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxMaxConnectionsMin + maxValue: xmuxMaxConnectionsMax + onMinChanged: xmuxMaxConnectionsMin = val + onMaxChanged: xmuxMaxConnectionsMax = val + } + + // cMaxReuseTimes + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("cMaxReuseTimes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxCMaxReuseTimesMin + maxValue: xmuxCMaxReuseTimesMax + onMinChanged: xmuxCMaxReuseTimesMin = val + onMaxChanged: xmuxCMaxReuseTimesMax = val + } + + // hMaxRequestTimes + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("hMaxRequestTimes") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxHMaxRequestTimesMin + maxValue: xmuxHMaxRequestTimesMax + onMinChanged: xmuxHMaxRequestTimesMin = val + onMaxChanged: xmuxHMaxRequestTimesMax = val + } + + // hMaxReusableSecs + CaptionTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + text: qsTr("hMaxReusableSecs") + color: AmneziaStyle.color.mutedGray + } + MinMaxRowType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + minValue: xmuxHMaxReusableSecsMin + maxValue: xmuxHMaxReusableSecsMax + onMinChanged: xmuxHMaxReusableSecsMin = val + onMaxChanged: xmuxHMaxReusableSecsMax = val + } + + TextFieldWithHeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + headerText: qsTr("hKeepAlivePeriod") + textField.text: xmuxHKeepAlivePeriod + textField.validator: IntValidator { + bottom: 0 + } + textField.onEditingFinished: { + if (textField.text !== xmuxHKeepAlivePeriod) xmuxHKeepAlivePeriod = textField.text + } + } + } + + Item { + Layout.preferredHeight: 16 + } + } + } + + BasicButtonType { + id: saveButton + + anchors.left: root.left + anchors.right: root.right + anchors.bottom: root.bottom + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottomMargin: 16 + PageController.safeAreaBottomMargin + + visible: listView.enabled && XrayConfigModel.hasUnsavedChanges + enabled: visible + text: qsTr("Save") + clickedFunc: function () { + var headerText = qsTr("Save settings?") + var descriptionText = qsTr("All users with whom you shared a connection with will no longer be able to connect to it.") + var yesButtonText = qsTr("Continue") + var noButtonText = qsTr("Cancel") + var yesButtonFunction = function () { + if (ConnectionController.isConnected && ServersModel.getDefaultServerData("defaultContainer") === ServersUiController.processedContainerIndex) { + PageController.showNotificationMessage(qsTr("Unable change settings while there is an active connection")) + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(ServersUiController.processedIndex, ServersUiController.processedContainerIndex, ProtocolEnum.Xray) + } + var noButtonFunction = function () { + if (typeof GC !== "undefined" && !GC.isMobile()) { + saveButton.forceActiveFocus() + } + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + } +} + diff --git a/client/ui/qml/Pages2/PageServiceMtProxySettings.qml b/client/ui/qml/Pages2/PageServiceMtProxySettings.qml new file mode 100644 index 000000000..a1c185104 --- /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.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex, true) + } else { + InstallController.setContainerEnabled(ServersUiController.getServerId(ServersUiController.processedServerIndex), 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.getServerId(ServersUiController.processedServerIndex), 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/PageServiceTelemtSettings.qml b/client/ui/qml/Pages2/PageServiceTelemtSettings.qml new file mode 100644 index 000000000..320300f98 --- /dev/null +++ b/client/ui/qml/Pages2/PageServiceTelemtSettings.qml @@ -0,0 +1,1447 @@ +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 "./" +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: TelemtConfigModel.transportModeStandard() + property string previousTlsDomain: TelemtConfigModel.defaultTlsDomain() + property string previousWorkersMode: TelemtConfigModel.workersModeAuto() + property string previousWorkers: TelemtConfigModel.defaultWorkers() + property bool previousNatEnabled: false + property string previousNatInternalIp: "" + property string previousNatExternalIp: "" + + 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 telemtNetworkBlocked: !NetworkReachabilityController.hasInternetAccess + readonly property bool navigationBlockedWhileBusy: isUpdating || diagLoading + + // Defer SSH/updateContainer so QML control handlers return before nested event loops run. + function telemtScheduleUpdate(closePage) { + var cp = closePage === undefined ? false : closePage + Qt.callLater(function () { + InstallController.updateContainer(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex, ProtocolEnum.Telemt, cp) + }) + } + + 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 = TelemtConfigModel.getTransportMode() + root.savedTlsDomain = TelemtConfigModel.getTlsDomain() + root.savedPublicHost = TelemtConfigModel.getPublicHost() + + if (!NetworkReachabilityController.hasInternetAccess) { + isCheckingStatus = false + return + } + isCheckingStatus = true + InstallController.refreshContainerStatus(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex) + } + + 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.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex) + } + } + } + + Connections { + target: InstallController + + function onUpdateContainerFinished(message, closePage) { + if (!root.visible) { + isUpdating = false + isCheckingStatus = false + return + } + isUpdating = false + containerStatus = 1 + root.savedTransportMode = TelemtConfigModel.getTransportMode() + root.savedTlsDomain = TelemtConfigModel.getTlsDomain() + root.savedPublicHost = TelemtConfigModel.getPublicHost() + PageController.showNotificationMessage(message) + if (closePage) { + PageController.closePage() + } + } + + function onInstallationErrorOccurred() { + if (!root.visible) { + isUpdating = false + isCheckingStatus = false + return + } + isUpdating = false + containerStatus = previousContainerStatus + TelemtConfigModel.setEnabled(previousEnabled) + TelemtConfigModel.setPort(previousPort) + TelemtConfigModel.setTag(previousTag) + TelemtConfigModel.setPublicHost(previousPublicHost) + TelemtConfigModel.setTransportMode(previousTransportMode) + TelemtConfigModel.setTlsDomain(previousTlsDomain) + TelemtConfigModel.setWorkersMode(previousWorkersMode) + TelemtConfigModel.setWorkers(previousWorkers) + TelemtConfigModel.setNatEnabled(previousNatEnabled) + TelemtConfigModel.setNatInternalIp(previousNatInternalIp) + TelemtConfigModel.setNatExternalIp(previousNatExternalIp) + } + + function onSetContainerEnabledFinished(enabled) { + if (!root.visible) { + isUpdating = false + return + } + if (enabled && pendingUpdateAfterEnable) { + pendingUpdateAfterEnable = false + root.telemtScheduleUpdate(false) + return + } + isUpdating = false + containerStatus = enabled ? 1 : 2 + PageController.showNotificationMessage( + enabled ? qsTr("Telemt started") : qsTr("Telemt stopped")) + } + + function onContainerStatusRefreshed(status) { + if (!root.visible) { + isCheckingStatus = false + return + } + isCheckingStatus = false + containerStatus = status + + root.savedTransportMode = TelemtConfigModel.getTransportMode() + root.savedTlsDomain = TelemtConfigModel.getTlsDomain() + root.savedPublicHost = TelemtConfigModel.getPublicHost() + if (status === 1) { + TelemtConfigModel.setEnabled(true) + InstallController.fetchContainerSecret(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex) + } else if (status === 2) { + TelemtConfigModel.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 + } + TelemtConfigModel.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("Telemt 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://github.com/telemt/telemt") + } + } + + 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: TelemtConfigModel + + 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 : TelemtConfigModel.defaultTlsDomain() + return "ee" + secret + domainToHex(domain) + } else if (mode === "padded") { + return "dd" + secret + } + // Telemt default (secure MTProto, not FakeTLS): Telegram proxy links require dd + hex secret + return "dd" + 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 Telemt 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("Telemt 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("Telemt 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 Telemt") + 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.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex) + } + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, function () { + }) + } + MouseArea { + anchors.fill: removeButton + cursorShape: Qt.PointingHandCursor + enabled: false + } + } + } + } + + ListViewType { + id: settingsListView + model: TelemtConfigModel + reuseItems: false + + delegate: ColumnLayout { + width: settingsListView.width + spacing: 0 + + SwitcherType { + id: enableTelemtSwitch + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + text: qsTr("Enable Telemt") + checked: isEnabled + enabled: !isCheckingStatus && containerStatus !== 0 && containerStatus !== 3 && !isUpdating + onToggled: function () { + if (checked !== isEnabled) { + previousEnabled = isEnabled + previousContainerStatus = containerStatus + isEnabled = checked + isUpdating = true + if (checked) { + root.pendingUpdateAfterEnable = true + InstallController.setContainerEnabled(ServersUiController.getServerId(ServersUiController.processedServerIndex), ServersUiController.processedContainerIndex, true) + } else { + InstallController.setContainerEnabled(ServersUiController.getServerId(ServersUiController.processedServerIndex), 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: { + showQuestionDrawer( + qsTr("Generate new secret?"), + qsTr("All existing connection links will stop working. Users will need new links."), + qsTr("Generate"), + qsTr("Cancel"), + function () { + if (containerStatus === 1) { + isUpdating = true + TelemtConfigModel.generateSecret() + root.telemtScheduleUpdate(false) + } else { + TelemtConfigModel.generateSecret() + PageController.showNotificationMessage(qsTr("New secret saved. It will be applied when Telemt 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.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== publicHost) { + publicHost = textField.text + TelemtConfigModel.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: TelemtConfigModel.defaultPort() + textField.maximumLength: 5 + textField.validator: IntValidator { + bottom: 1 + top: 65535 + } + Component.onCompleted: { + var savedPort = port + textField.text = (savedPort === TelemtConfigModel.defaultPort()) ? "" : savedPort + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + var portValue = textField.text === "" ? TelemtConfigModel.defaultPort() : textField.text + if (portValue !== port) { + port = portValue + TelemtConfigModel.setPort(port) + } + } + } + + 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 + } + + TextFieldWithHeaderType { + id: tagTextField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + headerText: qsTr("Promoted channel tag (optional)") + textField.placeholderText: qsTr("leave empty if not needed") + textField.text: tag + textField.maximumLength: 64 + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== tag) { + tag = textField.text + TelemtConfigModel.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") + } + } + } + + DropDownType { + id: transportModeDropDown + Layout.fillWidth: true + Layout.topMargin: 16 * 2 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + + drawerParent: root + drawerHeight: 0.35 + descriptionText: 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" + TelemtConfigModel.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 === TelemtConfigModel.defaultTlsDomain() || savedDomain === "") ? "" : savedDomain + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + var domainValue = textField.text === "" ? TelemtConfigModel.defaultTlsDomain() : textField.text + if (domainValue !== tlsDomain) { + tlsDomain = domainValue + TelemtConfigModel.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: RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 4 + spacing: 8 + CaptionTextType { + Layout.fillWidth: true + text: modelData + color: AmneziaStyle.color.paleGray + elide: Text.ElideMiddle + font.pixelSize: 13 + } + ImageButtonType { + implicitWidth: 32 + implicitHeight: 32 + hoverEnabled: true + image: "qrc:/images/controls/copy.svg" + imageColor: AmneziaStyle.color.mutedGray + onClicked: { GC.copyToClipBoard(modelData) + PageController.showNotificationMessage(qsTr("Copied")) } + } + ImageButtonType { + implicitWidth: 32 + implicitHeight: 32 + hoverEnabled: true + image: "qrc:/images/controls/trash.svg" + imageColor: AmneziaStyle.color.vibrantRed + onClicked: { + TelemtConfigModel.removeAdditionalSecret(index) + root.telemtScheduleUpdate(false) + } + } + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.topMargin: 8 + + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + text: qsTr("Add additional secret") + clickedFunc: function () { + TelemtConfigModel.addAdditionalSecret() + root.telemtScheduleUpdate(false) + } + } + + 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"; TelemtConfigModel.setWorkersMode("auto") } + } + HorizontalRadioButton { + Layout.fillWidth: true + text: qsTr("Manual") + ButtonGroup.group: workerModeGroup + checked: workersMode === "manual" + onClicked: { workersMode = "manual"; TelemtConfigModel.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: TelemtConfigModel.maxWorkers() + } + textField.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== workers) { + workers = textField.text + TelemtConfigModel.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 + TelemtConfigModel.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.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== natInternalIp) { + natInternalIp = textField.text + TelemtConfigModel.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.onEditingFinished: { + textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (textField.text !== natExternalIp) { + natExternalIp = textField.text + TelemtConfigModel.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.getServerId(ServersUiController.processedServerIndex), 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() + text: qsTr("Save") + clickedFunc: function () { + var portValue = portTextField.textField.text === "" + ? TelemtConfigModel.defaultPort() + : portTextField.textField.text + if (!portTextField.textField.acceptableInput && portTextField.textField.text !== "") { + portTextField.errorText = qsTr("The port must be in the range of 1 to 65535") + return + } + TelemtConfigModel.setPort(portValue) + TelemtConfigModel.setTag(tagTextField.textField.text) + TelemtConfigModel.setPublicHost(publicHostTextField.textField.text) + TelemtConfigModel.setTransportMode(transportMode) + var domainValue = tlsDomainTextField.textField.text === "" + ? TelemtConfigModel.defaultTlsDomain() + : tlsDomainTextField.textField.text + TelemtConfigModel.setTlsDomain(domainValue) + + if (transportMode === "faketls") { + workers = "0" + TelemtConfigModel.setWorkers("0") + } else { + TelemtConfigModel.setWorkersMode(workersMode) + TelemtConfigModel.setWorkers(workers) + } + TelemtConfigModel.setNatEnabled(natEnabled) + TelemtConfigModel.setNatInternalIp(natInternalIpTextField.textField.text) + TelemtConfigModel.setNatExternalIp(natExternalIpTextField.textField.text) + + previousPort = port + previousTag = tag + previousPublicHost = publicHost + previousTransportMode = transportMode + previousTlsDomain = tlsDomain + previousWorkersMode = workersMode + previousWorkers = workers + previousNatEnabled = natEnabled + previousNatInternalIp = natInternalIp + previousNatExternalIp = natExternalIp + isUpdating = true + root.telemtScheduleUpdate(false) + } + } + } + } + } + + Rectangle { + anchors.fill: parent + visible: isCheckingStatus || isUpdating || root.telemtNetworkBlocked + 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.telemtNetworkBlocked && !isCheckingStatus && !isUpdating + horizontalAlignment: Text.AlignHCenter + text: qsTr("No internet connection. Connect to the internet to change Telemt 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 950a5a7d8..5096be733 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 829e00a9b..ff583d5ed 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -79,7 +79,19 @@ Pages2/PageProtocolRaw.qml Pages2/PageProtocolWireGuardSettings.qml Pages2/PageProtocolXraySettings.qml + + Pages2/PageProtocolXraySnapshots.qml + Pages2/PageProtocolXrayFlowSettings.qml + Pages2/PageProtocolXraySecuritySettings.qml + Pages2/PageProtocolXrayTransportSettings.qml + Pages2/PageProtocolXrayXmuxSettings.qml + Pages2/PageProtocolXrayXPaddingSettings.qml + Pages2/PageProtocolXrayXPaddingBytesSettings.qml + Controls2/MinMaxRowType.qml + Pages2/PageServiceDnsSettings.qml + Pages2/PageServiceMtProxySettings.qml + Pages2/PageServiceTelemtSettings.qml Pages2/PageServiceSftpSettings.qml Pages2/PageServiceSocksProxySettings.qml Pages2/PageServiceTorWebsiteSettings.qml diff --git a/cmake/CPack.cmake b/cmake/CPack.cmake index 0a82f30db..7e91e5abd 100644 --- a/cmake/CPack.cmake +++ b/cmake/CPack.cmake @@ -1,5 +1,12 @@ set(CPACK_PACKAGE_VENDOR AmneziaVPN) set(CPACK_PACKAGE_VERSION ${AMNEZIAVPN_VERSION}) +if(WIN32) + set(CPACK_PACKAGE_FILE_NAME "AmneziaVPN_${AMNEZIAVPN_VERSION}_windows_x64") +elseif(APPLE AND NOT IOS AND NOT MACOS_NE) + set(CPACK_PACKAGE_FILE_NAME "AmneziaVPN_${AMNEZIAVPN_VERSION}_macos_x64") +elseif(LINUX AND NOT ANDROID) + set(CPACK_PACKAGE_FILE_NAME "AmneziaVPN_${AMNEZIAVPN_VERSION}_linux_x64") +endif() set(CPACK_PACKAGE_INSTALL_DIRECTORY AmneziaVPN) set(CPACK_PACKAGE_EXECUTABLES AmneziaVPN AmneziaVPN) set(CPACK_PRE_BUILD_SCRIPTS ${CMAKE_CURRENT_LIST_DIR}/sign_binaries.cmake) diff --git a/cmake/conan_provider.cmake b/cmake/conan_provider.cmake index 3a9fba602..9535e66cd 100644 --- a/cmake/conan_provider.cmake +++ b/cmake/conan_provider.cmake @@ -84,6 +84,10 @@ function(detect_os os os_api_level os_sdk os_subsystem os_version) set(_os_sdk "watch${apple_platform_suffix}") endif() endif() + # Macos does not support os.sdk + if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(_os_sdk "") + endif() if(DEFINED os_sdk) message(STATUS "CMake-Conan: cmake_osx_sysroot=${CMAKE_OSX_SYSROOT}") set(${os_sdk} ${_os_sdk} PARENT_SCOPE) diff --git a/conanfile.py b/conanfile.py index 35af87af8..03e9e3a89 100644 --- a/conanfile.py +++ b/conanfile.py @@ -33,7 +33,7 @@ class AmneziaVPN(ConanFile): if has_ne: self.requires("awg-apple/2.0.1") - self.requires("hev-socks5-tunnel/2.14.4", options={"as_framework": True}) + self.requires("hev-socks5-tunnel/2.15.0", options={"as_framework": True}) self.requires("openvpnadapter/1.0.0") if os == "Android": diff --git a/deploy/deploy_s3.sh b/deploy/deploy_s3.sh index e2df5f3f1..cd2a48fa8 100755 --- a/deploy/deploy_s3.sh +++ b/deploy/deploy_s3.sh @@ -38,9 +38,9 @@ download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${ download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_android9+_armeabi-v7a.apk download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_android9+_x86.apk download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_android9+_x86_64.apk -download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_linux_x64.tar -download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_macos.pkg -download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_x64.exe +download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_linux_x64.run +download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_macos_x64.pkg +download_file https://github.com/amnezia-vpn/amnezia-client/releases/download/${VERSION}/AmneziaVPN_${VERSION}_windows_x64.exe cd ../ diff --git a/recipes/amnezia-libxray/conanfile.py b/recipes/amnezia-libxray/conanfile.py index 187b05ae2..b69826084 100644 --- a/recipes/amnezia-libxray/conanfile.py +++ b/recipes/amnezia-libxray/conanfile.py @@ -2,7 +2,7 @@ from conan import ConanFile from conan.tools.files import get, copy from conan.tools.layout import basic_layout from conan.errors import ConanInvalidConfiguration -from conan.tools.env import VirtualBuildEnv, Environment +from conan.tools.env import Environment import os import stat @@ -34,7 +34,6 @@ class AmneziaLibxray(ConanFile): ) def generate(self): - VirtualBuildEnv(self).generate() env = Environment() ndk_path_str = self.conf.get("tools.android:ndk_path") if ndk_path_str: diff --git a/recipes/hev-socks5-tunnel/conanfile.py b/recipes/hev-socks5-tunnel/conanfile.py index 5cfc26dbf..c22ade2ab 100644 --- a/recipes/hev-socks5-tunnel/conanfile.py +++ b/recipes/hev-socks5-tunnel/conanfile.py @@ -15,7 +15,7 @@ required_conan_version = ">=2.26" class HevSocks5Tunnel(ConanFile): name = "hev-socks5-tunnel" - version = "2.14.4" + version = "2.15.0" settings = "os", "arch", "compiler" options = { "shared": [True, False], diff --git a/recipes/openvpn/conandata.yml b/recipes/openvpn/conandata.yml new file mode 100644 index 000000000..3052b33d1 --- /dev/null +++ b/recipes/openvpn/conandata.yml @@ -0,0 +1,4 @@ +patches: + "2.7.0": + - patch_file: "patches/0001-carefully-handle-CMAKE_GENERATOR_PLATFORM.patch" + - patch_file: "patches/0002-explicitly-pass-unicode-everywhere.patch" diff --git a/recipes/openvpn/conanfile.py b/recipes/openvpn/conanfile.py index 228ba66c1..d568e84df 100644 --- a/recipes/openvpn/conanfile.py +++ b/recipes/openvpn/conanfile.py @@ -1,5 +1,5 @@ from conan import ConanFile -from conan.tools.files import get, copy, replace_in_file +from conan.tools.files import get, copy, replace_in_file, apply_conandata_patches, export_conandata_patches from conan.tools.gnu import Autotools, AutotoolsToolchain, AutotoolsDeps, PkgConfigDeps from conan.tools.layout import basic_layout from conan.tools.cmake import cmake_layout, CMakeToolchain, CMake, CMakeDeps @@ -17,6 +17,7 @@ class Openvpn(ConanFile): return str(self.settings.os).startswith("Windows") def export_sources(self): + export_conandata_patches(self) copy(self, "*applink.c", src=self.recipe_folder, dst=self.export_sources_folder) def layout(self): @@ -84,6 +85,7 @@ class Openvpn(ConanFile): deps.generate() def build(self): + apply_conandata_patches(self) if self._is_windows: cmake = CMake(self) cmake.configure() diff --git a/recipes/openvpn/patches/0001-carefully-handle-CMAKE_GENERATOR_PLATFORM.patch b/recipes/openvpn/patches/0001-carefully-handle-CMAKE_GENERATOR_PLATFORM.patch new file mode 100644 index 000000000..10fa61bd8 --- /dev/null +++ b/recipes/openvpn/patches/0001-carefully-handle-CMAKE_GENERATOR_PLATFORM.patch @@ -0,0 +1,25 @@ +From 693bee38daaec5962ea3f0939c71e869f202c08a Mon Sep 17 00:00:00 2001 +From: Yaroslav Gurov +Date: Mon, 18 May 2026 16:58:00 +0200 +Subject: [PATCH] carefully handle CMAKE_GENERATOR_PLATFORM + +--- + CMakeLists.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 198c98ff..7341db70 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -108,7 +108,7 @@ if (MSVC) + "$<$:/OPT:REF>" + "$<$:/OPT:ICF>" + ) +- if (${CMAKE_GENERATOR_PLATFORM} STREQUAL "x64" OR ${CMAKE_GENERATOR_PLATFORM} STREQUAL "x86") ++ if ("${CMAKE_GENERATOR_PLATFORM}" STREQUAL "x64" OR "${CMAKE_GENERATOR_PLATFORM}" STREQUAL "x86") + add_link_options("$<$:/CETCOMPAT>") + endif() + else () +-- +2.46.0.windows.1 + diff --git a/recipes/openvpn/patches/0002-explicitly-pass-unicode-everywhere.patch b/recipes/openvpn/patches/0002-explicitly-pass-unicode-everywhere.patch new file mode 100644 index 000000000..538d403a6 --- /dev/null +++ b/recipes/openvpn/patches/0002-explicitly-pass-unicode-everywhere.patch @@ -0,0 +1,50 @@ +From 9a42a0350abaa1a329ad56e40b1900ef78183323 Mon Sep 17 00:00:00 2001 +From: Yaroslav Gurov +Date: Mon, 18 May 2026 18:05:09 +0200 +Subject: [PATCH] explicitly pass unicode everywhere + +--- + src/openvpnmsica/CMakeLists.txt | 1 + + src/openvpnserv/CMakeLists.txt | 1 + + src/tapctl/CMakeLists.txt | 1 + + 3 files changed, 3 insertions(+) + +diff --git a/src/openvpnmsica/CMakeLists.txt b/src/openvpnmsica/CMakeLists.txt +index 9126b80f..23f979d6 100644 +--- a/src/openvpnmsica/CMakeLists.txt ++++ b/src/openvpnmsica/CMakeLists.txt +@@ -22,6 +22,7 @@ target_sources(openvpnmsica PRIVATE + openvpnmsica_resources.rc + ) + target_compile_options(openvpnmsica PRIVATE ++ -DUNICODE + -D_UNICODE + -UNTDDI_VERSION + -D_WIN32_WINNT=_WIN32_WINNT_VISTA +diff --git a/src/openvpnserv/CMakeLists.txt b/src/openvpnserv/CMakeLists.txt +index fc153822..b3a0cff1 100644 +--- a/src/openvpnserv/CMakeLists.txt ++++ b/src/openvpnserv/CMakeLists.txt +@@ -19,6 +19,7 @@ function(add_common_options target) + ${MC_GEN_DIR} + ) + target_compile_options(${target} PRIVATE ++ -DUNICODE + -D_UNICODE + -UNTDDI_VERSION + -D_WIN32_WINNT=_WIN32_WINNT_VISTA +diff --git a/src/tapctl/CMakeLists.txt b/src/tapctl/CMakeLists.txt +index 97702c01..81da46b8 100644 +--- a/src/tapctl/CMakeLists.txt ++++ b/src/tapctl/CMakeLists.txt +@@ -19,6 +19,7 @@ target_sources(tapctl PRIVATE + tapctl_resources.rc + ) + target_compile_options(tapctl PRIVATE ++ -DUNICODE + -D_UNICODE + -UNTDDI_VERSION + -D_WIN32_WINNT=_WIN32_WINNT_VISTA +-- +2.46.0.windows.1 +