From 8327de66dda21c4fc030cce9a89f8354c0177254 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Tue, 21 Apr 2026 16:55:47 +0300 Subject: [PATCH 01/13] test capcha --- client/core/api/apiUtils.cpp | 56 ++++-- client/core/controllers/gatewayController.cpp | 15 +- client/core/defs.h | 3 + .../controllers/api/apiConfigsController.cpp | 90 +++++++++ .../ui/controllers/api/apiConfigsController.h | 15 ++ client/ui/qml/Controls2/CaptchaDialogType.qml | 186 ++++++++++++++++++ client/ui/qml/main2.qml | 22 +++ 7 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 client/ui/qml/Controls2/CaptchaDialogType.qml diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 5ed46d1f1..92723a046 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -30,6 +30,15 @@ namespace return value.isString() ? value.toString().trimmed() : QString(); } + QString apiErrorTokenFromJson(const QJsonObject &jsonObj) + { + const QString message = apiErrorMessageFromJson(jsonObj); + if (!message.isEmpty()) { + return message; + } + return jsonObj.value(QStringLiteral("error")).toString().trimmed(); + } + QString escapeUnicode(const QString &input) { QString output; @@ -137,15 +146,13 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl const int httpStatusCodeNotFound = 404; const int httpStatusCodeNotImplemented = 501; const int httpStatusCodePaymentRequired = 402; + const int httpStatusCodeTooManyRequests = 429; const int httpStatusCodeUnprocessableEntity = 422; if (!sslErrors.empty()) { qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; } - if (replyError == QNetworkReply::NoError) { - return amnezia::ErrorCode::NoError; - } if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; @@ -163,32 +170,59 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); - const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1); - if (httpStatusFromBody == httpStatusCodeConflict) { + int status = jsonObj.value(QStringLiteral("http_status")).toInt(-1); + if (status < 0) { + status = httpStatusCode; + } + + if (status == httpStatusCodeTooManyRequests) { + return amnezia::ErrorCode::ApiRateLimitError; + } + if (status == httpStatusCodeConflict) { if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) { return amnezia::ErrorCode::ApiTrialAlreadyUsedError; } return amnezia::ErrorCode::ApiConfigLimitError; } - if (httpStatusFromBody == httpStatusCodeNotFound) { + if (status == httpStatusCodeNotFound) { return amnezia::ErrorCode::ApiNotFoundError; } - if (httpStatusFromBody == httpStatusCodeNotImplemented) { + if (status == httpStatusCodeNotImplemented) { return amnezia::ErrorCode::ApiUpdateRequestError; } - if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) { + if (status == httpStatusCodeUnprocessableEntity) { if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) { return amnezia::ErrorCode::ApiSubscriptionExpiredError; } return amnezia::ErrorCode::ApiConfigDownloadError; } - if (httpStatusFromBody == httpStatusCodePaymentRequired) { + if (status == httpStatusCodePaymentRequired) { + const QString errorToken = apiErrorTokenFromJson(jsonObj); + if (errorToken.contains(QLatin1String("invalid_captcha"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaInvalidError; + } + if (jsonObj.contains(QStringLiteral("captcha_id")) || jsonObj.contains(QStringLiteral("captcha_image")) + || errorToken.compare(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive) == 0 + || errorToken.contains(QLatin1String("rate_limit_exceeded"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaRequiredError; + } return amnezia::ErrorCode::ApiSubscriptionNotActiveError; } - return amnezia::ErrorCode::ApiConfigDownloadError; + + if (replyError == QNetworkReply::NoError && status > 0 && status < 400) { + return amnezia::ErrorCode::NoError; + } + + if (status >= 400) { + return amnezia::ErrorCode::ApiConfigDownloadError; + } } - qDebug() << "something went wrong"; + if (replyError == QNetworkReply::NoError) { + return amnezia::ErrorCode::NoError; + } + + qDebug() << "something went wrong" << replyErrorString; return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 30b4c572d..8be091b3e 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -216,6 +216,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); if (errorCode) { + responseBody = decryptionResult.decryptedBody; return errorCode; } @@ -263,7 +264,7 @@ QFuture> GatewayController::postAsync(const QString auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); if (errorCode) { - promise->addResult(qMakePair(errorCode, QByteArray())); + promise->addResult(qMakePair(errorCode, decryptionResult.decryptedBody)); promise->finish(); return; } @@ -434,6 +435,18 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed(); } } else { + // Plaintext JSON error (e.g. HTTP 402 CAPTCHA) is not encrypted — do not treat as proxy failure. + const QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + const QJsonObject jsonObj = jsonDoc.object(); + if (jsonObj.contains(QStringLiteral("captcha_id")) || jsonObj.contains(QStringLiteral("captcha_image"))) { + return false; + } + const QString err = jsonObj.value(QStringLiteral("error")).toString(); + if (err.contains(QLatin1String("captcha"), Qt::CaseInsensitive) || err == QLatin1String("rate_limit_exceeded")) { + return false; + } + } qDebug() << "failed to decrypt the data"; return true; } diff --git a/client/core/defs.h b/client/core/defs.h index 731af38ed..c343affb2 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -126,6 +126,9 @@ namespace amnezia ApiSubscriptionNotActiveError = 1114, ApiNoPurchasedSubscriptionsError = 1115, ApiTrialAlreadyUsedError = 1116, + ApiCaptchaRequiredError = 1117, + ApiCaptchaInvalidError = 1118, + ApiRateLimitError = 1119, // QFile errors OpenError = 1200, diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index d55a74dbd..fdf8e9ab0 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -858,6 +858,28 @@ bool ApiConfigsController::importFreeFromGateway() m_serversModel->addServer(serverConfig); emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); return true; + } else if (errorCode == ErrorCode::ApiCaptchaRequiredError) { + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + QString captchaId = jsonObj.value("captcha_id").toString(); + QString captchaImage = jsonObj.value("captcha_image").toString(); + QString hint = jsonObj.value("hint").toString(tr("Please solve the CAPTCHA to continue")); + + m_captchaState.apiPayload = apiPayload; + m_captchaState.endpoint = QString("%1v1/config"); + m_captchaState.serviceProtocol = gatewayRequestData.serviceProtocol; + m_captchaState.openvpnPrivKey = protocolData.certRequest.privKey; + m_captchaState.wireguardClientPrivKey = protocolData.wireGuardClientPrivKey; + m_captchaState.wireguardClientPubKey = protocolData.wireGuardClientPubKey; + m_captchaState.xrayUuid = protocolData.xrayUuid; + m_captchaState.isPending = true; + + emit captchaRequired(captchaId, captchaImage, hint); + return false; + } + emit errorOccurred(errorCode); + return false; } else { emit errorOccurred(errorCode); return false; @@ -1276,3 +1298,71 @@ ErrorCode ApiConfigsController::executeRequest(const QString &endpoint, const QJ apiDefs::requestTimeoutMsecs, m_settings->isStrictKillSwitchEnabled()); return gatewayController.post(endpoint, apiPayload, responseBody); } + +void ApiConfigsController::onCaptchaSolved(const QString &captchaId, const QString &solution) +{ + if (!m_captchaState.isPending) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + m_captchaState.isPending = false; + + QJsonObject apiPayload = m_captchaState.apiPayload; + apiPayload.insert("captcha_id", captchaId); + apiPayload.insert("captcha_solution", solution); + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(m_captchaState.endpoint, apiPayload, responseBody); + + if (errorCode == ErrorCode::ApiCaptchaInvalidError) { + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + QString newCaptchaId = jsonObj.value("captcha_id").toString(); + QString newCaptchaImage = jsonObj.value("captcha_image").toString(); + QString hint = jsonObj.value("hint").toString(tr("Invalid CAPTCHA. Please try again")); + + m_captchaState.apiPayload = apiPayload; + m_captchaState.isPending = true; + + emit captchaRequired(newCaptchaId, newCaptchaImage, hint); + return; + } + emit errorOccurred(errorCode); + return; + } + + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + // Reconstruct ProtocolData from saved state + ProtocolData protocolData; + protocolData.certRequest.privKey = m_captchaState.openvpnPrivKey; + protocolData.wireGuardClientPrivKey = m_captchaState.wireguardClientPrivKey; + protocolData.wireGuardClientPubKey = m_captchaState.wireguardClientPubKey; + protocolData.xrayUuid = m_captchaState.xrayUuid; + + QJsonObject serverConfig; + errorCode = fillServerConfig(m_captchaState.serviceProtocol, protocolData, responseBody, serverConfig); + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonObject apiConfig = serverConfig.value(configKey::apiConfig).toObject(); + apiConfig.insert(configKey::userCountryCode, m_apiServicesModel->getCountryCode()); + apiConfig.insert(configKey::serviceType, m_apiServicesModel->getSelectedServiceType()); + apiConfig.insert(configKey::serviceProtocol, m_apiServicesModel->getSelectedServiceProtocol()); + + serverConfig.insert(configKey::apiConfig, apiConfig); + + QJsonObject authData = serverConfig.value(configKey::authData).toObject(); + authData.insert(QStringLiteral("captcha_solution"), solution); + serverConfig.insert(configKey::authData, authData); + + m_serversModel->addServer(serverConfig); + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); +} diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index 68f6565d0..e3f8717a9 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -47,6 +47,8 @@ public slots: void setCurrentProtocol(const QString &protocolName); bool isVlessProtocol(); + void onCaptchaSolved(const QString &captchaId, const QString &solution); + signals: void errorOccurred(ErrorCode errorCode); void trialEmailError(const QString &message); @@ -59,6 +61,7 @@ signals: void updateServerFromApiFinished(); void vpnKeyExportReady(); + void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint); private: QList getQrCodes(); @@ -77,6 +80,18 @@ private: QSharedPointer m_subscriptionPlansModel; QSharedPointer m_benefitsModel; + + // CAPTCHA handling state + struct CaptchaState { + QJsonObject apiPayload; + QString endpoint; + QString serviceProtocol; + QString openvpnPrivKey; + QString wireguardClientPrivKey; + QString wireguardClientPubKey; + QString xrayUuid; + bool isPending = false; + } m_captchaState; }; #endif diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml new file mode 100644 index 000000000..5468c653a --- /dev/null +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -0,0 +1,186 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style 1.0 + +import "TextTypes" +import "../Config" + +Popup { + id: root + + property string captchaId + property string captchaImageBase64 + property string hint: "Please solve the CAPTCHA to continue" + + signal captchaSolved(string captchaId, string solution) + + leftMargin: 25 + rightMargin: 25 + bottomMargin: 70 + SettingsController.safeAreaBottomMargin + + width: parent.width - leftMargin - rightMargin + + anchors.centerIn: parent + modal: true + closePolicy: Popup.NoAutoClose + + Overlay.modal: Rectangle { + color: AmneziaStyle.color.translucentMidnightBlack + } + + onOpened: { + timer.start() + solutionInput.text = "" + solutionInput.focus = true + } + + onClosed: { + FocusController.dropRootObject(root) + } + + background: Rectangle { + anchors.fill: parent + color: "white" + radius: 4 + } + + Timer { + id: timer + interval: 200 + onTriggered: { + FocusController.pushRootObject(root) + FocusController.setFocusItem(solutionInput) + } + repeat: false + running: true + } + + contentItem: Item { + implicitWidth: contentLayout.implicitWidth + implicitHeight: contentLayout.implicitHeight + + anchors.fill: parent + + ColumnLayout { + id: contentLayout + + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 16 + anchors.bottomMargin: 16 + + spacing: 12 + + CaptionTextType { + text: qsTr("CAPTCHA Verification") + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + } + + ParagraphTextType { + text: hint + Layout.fillWidth: true + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignLeft + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 200 + color: AmneziaStyle.color.lightGray + radius: 4 + + Image { + id: captchaImage + anchors.centerIn: parent + cache: false + + Component.onCompleted: { + if (captchaImageBase64 !== "") { + source = "data:image/png;base64," + captchaImageBase64 + } + } + + Connections { + target: root + function onCaptchaImageBase64Changed() { + captchaImage.source = "data:image/png;base64," + root.captchaImageBase64 + } + } + } + + BusyIndicator { + anchors.centerIn: parent + running: captchaImage.status === Image.Loading + } + } + + ParagraphTextType { + text: qsTr("Enter the numbers from the image:") + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + } + + TextField { + id: solutionInput + + Layout.fillWidth: true + implicitHeight: 40 + + placeholderText: qsTr("Enter CAPTCHA solution") + + background: Rectangle { + border.color: AmneziaStyle.color.charcoalGray + border.width: 1 + radius: 4 + color: "white" + } + + onAccepted: { + if (solutionInput.text.trim() !== "") { + root.captchaSolved(root.captchaId, solutionInput.text.trim()) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + BasicButtonType { + id: submitButton + + Layout.fillWidth: true + implicitHeight: 40 + + text: qsTr("Submit") + + onClicked: { + if (solutionInput.text.trim() !== "") { + root.captchaSolved(root.captchaId, solutionInput.text.trim()) + } + } + } + + BasicButtonType { + id: cancelButton + + Layout.fillWidth: true + implicitHeight: 40 + + text: qsTr("Cancel") + defaultColor: AmneziaStyle.color.lightGray + hoveredColor: AmneziaStyle.color.charcoalGray + textColor: AmneziaStyle.color.midnightBlack + + onClicked: { + root.close() + } + } + } + } + } +} diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 147f90b8b..b42b1a6b1 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -201,6 +201,21 @@ Window { } } + Item { + objectName: "captchaDialogItem" + + anchors.fill: parent + + CaptchaDialogType { + id: captchaDialog + + onCaptchaSolved: function(captchaId, solution) { + captchaDialog.close() + ApiConfigsController.onCaptchaSolved(captchaId, solution) + } + } + } + Item { objectName: "privateKeyPassphraseDrawerItem" @@ -306,6 +321,13 @@ Window { function onSubscriptionExpiredOnServer() { subscriptionExpiredDrawer.openTriggered() } + + function onCaptchaRequired(captchaId, captchaImageBase64, hint) { + captchaDialog.captchaId = captchaId + captchaDialog.captchaImageBase64 = captchaImageBase64 + captchaDialog.hint = hint + captchaDialog.open() + } } Connections { From 6c91b41bfaae641ef2c200595ad37953257a54b5 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 14:57:07 +0300 Subject: [PATCH 02/13] add test AMNEZIA_GATEWAY_PLAINTEXT_MOCK --- client/core/controllers/gatewayController.cpp | 45 ++++++++++++++++++- client/resources.qrc | 1 + client/settings.cpp | 8 ++++ .../controllers/api/apiConfigsController.cpp | 5 +++ .../ui/controllers/api/apiConfigsController.h | 2 + .../qml/Pages2/PageSetupWizardApiFreeInfo.qml | 3 ++ 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 8be091b3e..80ec9e383 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -49,6 +49,18 @@ namespace constexpr int httpStatusCodeUnprocessableEntity = 422; constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); + +#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK + bool gatewayUrlIsPlaintextMockTarget(const QString &gatewayEndpoint, const QString &proxyUrl) + { + const auto hostOf = [](const QString &urlString) -> QString { + return QUrl(urlString).host().toLower(); + }; + const QString host = proxyUrl.isEmpty() ? hostOf(gatewayEndpoint) : hostOf(proxyUrl); + return host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1") + || host == QLatin1String("[::1]"); + } +#endif } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -76,6 +88,16 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const encRequestData.request.setRawHeader(QString("X-Client-Request-ID").toUtf8(), QUuid::createUuid().toString(QUuid::WithoutBraces).toUtf8()); encRequestData.request.setUrl(endpoint.arg(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl)); +#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK + if (gatewayUrlIsPlaintextMockTarget(m_gatewayEndpoint, m_proxyUrl)) { + encRequestData.requestBody = QJsonDocument(apiPayload).toJson(); + encRequestData.key.clear(); + encRequestData.iv.clear(); + encRequestData.salt.clear(); + return encRequestData; + } +#endif + // bypass killSwitch exceptions for API-gateway #ifdef AMNEZIA_DESKTOP if (m_isStrictKillSwitchEnabled) { @@ -181,10 +203,18 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api reply->deleteLater(); +#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK + const bool plaintextMock = encRequestData.key.isEmpty(); + DecryptionResult decryptionResult; + decryptionResult.decryptedBody = encryptedResponseBody; + decryptionResult.isDecryptionSuccessful = true; +#else + const bool plaintextMock = false; auto decryptionResult = tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); +#endif - if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (!plaintextMock && sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) { encRequestData.request.setUrl(url); return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); @@ -255,8 +285,18 @@ QFuture> GatewayController::postAsync(const QString reply->deleteLater(); +#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK + const bool plaintextMock = encRequestData.key.isEmpty(); + DecryptionResult decryptionResult; + decryptionResult.decryptedBody = encryptedResponseBody; + decryptionResult.isDecryptionSuccessful = true; +#else + // TODO + /// Temp var plaintextMock + const bool plaintextMock = false; auto decryptionResult = tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); +#endif auto processResponse = [promise, encRequestData](const GatewayController::DecryptionResult &decryptionResult, const QList &sslErrors, QNetworkReply::NetworkError replyError, @@ -281,7 +321,8 @@ QFuture> GatewayController::postAsync(const QString promise->finish(); }; - if (sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (!plaintextMock && sslErrors->isEmpty() + && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); diff --git a/client/resources.qrc b/client/resources.qrc index 51b378af5..15cb4894a 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -152,6 +152,7 @@ ui/qml/Controls2/BackButtonType.qml ui/qml/Controls2/BasicButtonType.qml ui/qml/Controls2/BusyIndicatorType.qml + ui/qml/Controls2/CaptchaDialogType.qml ui/qml/Controls2/CardType.qml ui/qml/Controls2/CardWithIconsType.qml ui/qml/Controls2/CheckBoxType.qml diff --git a/client/settings.cpp b/client/settings.cpp index 2f7b24cbe..23f826473 100644 --- a/client/settings.cpp +++ b/client/settings.cpp @@ -14,7 +14,11 @@ namespace const char cloudFlareNs1[] = "1.1.1.1"; const char cloudFlareNs2[] = "1.0.0.1"; +#if AMNEZIA_FORCE_DEV_GATEWAY + constexpr char gatewayEndpoint[] = DEFAULT_GATEWAY_ENDPOINT; +#else constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/"; +#endif } Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_NAME, APPLICATION_NAME, this) @@ -44,6 +48,10 @@ Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_N } m_gatewayEndpoint = gatewayEndpoint; +#if AMNEZIA_FORCE_DEV_GATEWAY + m_settings.setValue(QStringLiteral("Conf/devGatewayEnv"), true); + m_gatewayEndpoint = QString::fromUtf8(DEV_AGW_ENDPOINT); +#endif } int Settings::serversCount() const diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index fdf8e9ab0..3d82c779c 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -438,6 +438,11 @@ ApiConfigsController::ApiConfigsController(const QSharedPointer &s }); } +bool ApiConfigsController::isCaptchaAwaitingUser() const +{ + return m_captchaState.isPending; +} + bool ApiConfigsController::exportVpnKey(const QString &fileName) { if (fileName.isEmpty()) { diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index e3f8717a9..a7ad04697 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -19,6 +19,8 @@ public: const QSharedPointer &benefitsModel, const std::shared_ptr &settings, QObject *parent = nullptr); + Q_INVOKABLE bool isCaptchaAwaitingUser() const; + Q_PROPERTY(QList qrCodes READ getQrCodes NOTIFY vpnKeyExportReady) Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady) Q_PROPERTY(QString vpnKey READ getVpnKey NOTIFY vpnKeyExportReady) diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml index 507e0d621..1ef6dac2b 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -130,6 +130,9 @@ PageType { PageController.showBusyIndicator(false) if (!result) { + if (ApiConfigsController.isCaptchaAwaitingUser()) { + return + } var endpoint = ApiServicesModel.getStoreEndpoint() Qt.openUrlExternally(endpoint) PageController.closePage() From 576668dce6f251ed045b75a484663c321c199bde Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 15:02:08 +0300 Subject: [PATCH 03/13] ref --- client/core/api/apiUtils.cpp | 3 +++ client/core/controllers/gatewayController.cpp | 4 +--- client/ui/controllers/api/apiConfigsController.h | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 92723a046..b5195a1fd 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -153,6 +153,9 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; } + if (replyError == QNetworkReply::NoError) { + return amnezia::ErrorCode::NoError; + } if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 80ec9e383..5430cd7b6 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -246,7 +246,6 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); if (errorCode) { - responseBody = decryptionResult.decryptedBody; return errorCode; } @@ -321,8 +320,7 @@ QFuture> GatewayController::postAsync(const QString promise->finish(); }; - if (!plaintextMock && sslErrors->isEmpty() - && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (!plaintextMock && sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index a7ad04697..f732d7cc3 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -83,7 +83,6 @@ private: QSharedPointer m_subscriptionPlansModel; QSharedPointer m_benefitsModel; - // CAPTCHA handling state struct CaptchaState { QJsonObject apiPayload; QString endpoint; From bdf8a292ffc874296f87f227bdf6fd78ee3d5820 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 15:11:00 +0300 Subject: [PATCH 04/13] remove first QNetworkReply::NoError --- client/core/api/apiUtils.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index b5195a1fd..92723a046 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -153,9 +153,6 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl qDebug().noquote() << sslErrors; return amnezia::ErrorCode::ApiConfigSslError; } - if (replyError == QNetworkReply::NoError) { - return amnezia::ErrorCode::NoError; - } if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) { qDebug() << replyError; From 73dc6ba6f157beaa95ea99069cb72f576a55bc62 Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 15:29:16 +0300 Subject: [PATCH 05/13] fixed macros --- client/CMakeLists.txt | 4 ++++ client/core/controllers/gatewayController.cpp | 8 ++++---- client/settings.cpp | 7 +++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 0f3ae7a0f..16ab21dde 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -33,6 +33,10 @@ add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}") add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}") add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}") +if(AMNEZIA_LOCAL_GATEWAY) + add_definitions(-DAMNEZIA_LOCAL_GATEWAY="$ENV{AMNEZIA_LOCAL_GATEWAY}") +endif() + if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID)) set(PACKAGES ${PACKAGES} Widgets) endif() diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 5430cd7b6..9bc6e675c 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -50,7 +50,7 @@ namespace constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); -#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK +#ifdef AMNEZIA_LOCAL_GATEWAY bool gatewayUrlIsPlaintextMockTarget(const QString &gatewayEndpoint, const QString &proxyUrl) { const auto hostOf = [](const QString &urlString) -> QString { @@ -88,7 +88,7 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const encRequestData.request.setRawHeader(QString("X-Client-Request-ID").toUtf8(), QUuid::createUuid().toString(QUuid::WithoutBraces).toUtf8()); encRequestData.request.setUrl(endpoint.arg(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl)); -#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK +#ifdef AMNEZIA_LOCAL_GATEWAY if (gatewayUrlIsPlaintextMockTarget(m_gatewayEndpoint, m_proxyUrl)) { encRequestData.requestBody = QJsonDocument(apiPayload).toJson(); encRequestData.key.clear(); @@ -203,7 +203,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api reply->deleteLater(); -#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK +#ifdef AMNEZIA_LOCAL_GATEWAY const bool plaintextMock = encRequestData.key.isEmpty(); DecryptionResult decryptionResult; decryptionResult.decryptedBody = encryptedResponseBody; @@ -284,7 +284,7 @@ QFuture> GatewayController::postAsync(const QString reply->deleteLater(); -#if AMNEZIA_GATEWAY_PLAINTEXT_MOCK +#ifdef AMNEZIA_LOCAL_GATEWAY const bool plaintextMock = encRequestData.key.isEmpty(); DecryptionResult decryptionResult; decryptionResult.decryptedBody = encryptedResponseBody; diff --git a/client/settings.cpp b/client/settings.cpp index 23f826473..c1d209655 100644 --- a/client/settings.cpp +++ b/client/settings.cpp @@ -14,8 +14,8 @@ namespace const char cloudFlareNs1[] = "1.1.1.1"; const char cloudFlareNs2[] = "1.0.0.1"; -#if AMNEZIA_FORCE_DEV_GATEWAY - constexpr char gatewayEndpoint[] = DEFAULT_GATEWAY_ENDPOINT; +#ifdef AMNEZIA_LOCAL_GATEWAY + constexpr char gatewayEndpoint[] = "http://localhost:8080/"; #else constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/"; #endif @@ -48,9 +48,8 @@ Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_N } m_gatewayEndpoint = gatewayEndpoint; -#if AMNEZIA_FORCE_DEV_GATEWAY +#ifdef AMNEZIA_LOCAL_GATEWAY m_settings.setValue(QStringLiteral("Conf/devGatewayEnv"), true); - m_gatewayEndpoint = QString::fromUtf8(DEV_AGW_ENDPOINT); #endif } From 0669939c9d1aeeef2b2c9e22f4f84f76e1d1d32e Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 16:35:19 +0300 Subject: [PATCH 06/13] fixed http code --- client/core/api/apiUtils.cpp | 3 +++ client/core/controllers/gatewayController.cpp | 6 +++--- client/core/errorstrings.cpp | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index 92723a046..c1475b5c7 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -174,6 +174,9 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl if (status < 0) { status = httpStatusCode; } + if (status == 0 && httpStatusCode >= 400) { + status = httpStatusCode; + } if (status == httpStatusCodeTooManyRequests) { return amnezia::ErrorCode::ApiRateLimitError; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 9bc6e675c..9ec361882 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -243,8 +243,9 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api bypassProxy(endpoint, serviceType, userCountryCode, requestFunction, replyProcessingFunction); } - auto errorCode = - apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, decryptionResult.decryptedBody); + responseBody = decryptionResult.decryptedBody; + const auto errorCode = + apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, responseBody); if (errorCode) { return errorCode; } @@ -254,7 +255,6 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api return ErrorCode::ApiConfigDecryptionError; } - responseBody = decryptionResult.decryptedBody; return ErrorCode::NoError; } diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 4f5262cde..c024f5028 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -83,6 +83,9 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break; + case (ErrorCode::ApiCaptchaRequiredError): errorMessage = QObject::tr("CAPTCHA verification is required"); break; + case (ErrorCode::ApiCaptchaInvalidError): errorMessage = QObject::tr("CAPTCHA was incorrect. Please try again"); break; + case (ErrorCode::ApiRateLimitError): errorMessage = QObject::tr("Too many requests. Please try again later"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; From ccf005eb9a642f960a255a1d2179caee650a051f Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 16:46:05 +0300 Subject: [PATCH 07/13] add test server --- tools/local_gateway/go.mod | 5 ++ tools/local_gateway/go.sum | 2 + tools/local_gateway/main.go | 174 ++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 tools/local_gateway/go.mod create mode 100644 tools/local_gateway/go.sum create mode 100644 tools/local_gateway/main.go diff --git a/tools/local_gateway/go.mod b/tools/local_gateway/go.mod new file mode 100644 index 000000000..35b1e3edf --- /dev/null +++ b/tools/local_gateway/go.mod @@ -0,0 +1,5 @@ +module gateway_plaintext_mock + +go 1.21 + +require github.com/dchest/captcha v1.1.0 diff --git a/tools/local_gateway/go.sum b/tools/local_gateway/go.sum new file mode 100644 index 000000000..637f197a4 --- /dev/null +++ b/tools/local_gateway/go.sum @@ -0,0 +1,2 @@ +github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= +github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go new file mode 100644 index 000000000..49da64f0f --- /dev/null +++ b/tools/local_gateway/main.go @@ -0,0 +1,174 @@ +// Plaintext mock for AmneziaVPN client (CMake AMNEZIA_LOCAL_GATEWAY=ON + localhost DEV_AGW_ENDPOINT). +// No RSA/AES — POST JSON is the same object the client sends inside api_payload when encrypted. +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "log" + "net/http" + "sync" + "time" + + "github.com/dchest/captcha" +) + +func shortID(id string) string { + if len(id) <= 10 { + return id + } + return id[:10] + "…" +} + +// Set to 5 to mimic "more than 5 requests per 24h". Set to 0 so the first amnezia-free request returns CAPTCHA (faster UI test). +const rateLimitExcessAfter = 0 + +var ( + mu sync.Mutex + requests = map[string][]time.Time{} // installation_uuid -> timestamps (sliding window simplified: count in session) +) + +func pruneRequests(uuid string) { + now := time.Now() + cutoff := now.Add(-24 * time.Hour) + var kept []time.Time + for _, t := range requests[uuid] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + requests[uuid] = kept +} + +func overLimit(uuid string) bool { + pruneRequests(uuid) + return len(requests[uuid]) > rateLimitExcessAfter +} + +func handleServices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + // Minimal shape for ApiServicesModel::updateModel + importFreeFromGateway (service_protocol "awg"). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "user_country_code": "ZZ", + "services": []map[string]any{ + { + "service_type": "amnezia-free", + "service_protocol": "awg", + "service_info": map[string]any{}, + "is_available": true, + "service_description": map[string]any{ + "service_name": "Amnezia Free (mock)", + "card_description": "Local plaintext mock", + "description": "For CAPTCHA UI test only", + }, + "available_countries": []any{}, + }, + }, + }) +} + +func handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "json", http.StatusBadRequest) + return + } + + st, _ := body["service_type"].(string) + if st != "amnezia-free" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "mock: only amnezia-free"}) + return + } + + uuid, _ := body["installation_uuid"].(string) + if uuid == "" { + uuid = "anonymous" + } + + captchaID, _ := body["captcha_id"].(string) + solution, _ := body["captcha_solution"].(string) + + if captchaID != "" && solution != "" { + if captcha.VerifyString(captchaID, solution) { + mu.Lock() + requests[uuid] = nil + mu.Unlock() + log.Printf("captcha VERIFIED id=%s uuid=%s (dchest.VerifyString ok) -> HTTP 200", shortID(captchaID), uuid) + // HTTP 200, no http_status:501 in body — client maps 501 to ApiUpdateRequestError ("update the app"). + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "captcha_verified": true, + "message": "mock gateway: captcha ok — no vpn:// config in this mock (expect empty-config error in client)", + }) + return + } + log.Printf("captcha REJECTED id=%s uuid=%s solution_len=%d (dchest.VerifyString failed) -> HTTP 402 invalid_captcha", + shortID(captchaID), uuid, len(solution)) + var buf bytes.Buffer + id := captcha.NewLen(6) + _ = captcha.WriteImage(&buf, id, 240, 80) + b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_captcha", + "captcha_id": id, + "captcha_image": b64, + "hint": "Try again", + }) + return + } + + mu.Lock() + requests[uuid] = append(requests[uuid], time.Now()) + limit := overLimit(uuid) + mu.Unlock() + + if limit { + var buf bytes.Buffer + id := captcha.NewLen(6) + _ = captcha.WriteImage(&buf, id, 240, 80) + b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + log.Printf("captcha ISSUED id=%s uuid=%s (402 rate_limit_exceeded)", shortID(id), uuid) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "rate_limit_exceeded", + "captcha_id": id, + "captcha_image": b64, + "hint": "Please solve the CAPTCHA to continue", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "mock: under rate limit, no config payload", + }) +} + +func main() { + http.HandleFunc("/v1/services", handleServices) + http.HandleFunc("/v1/config", handleConfig) + log.Println("plaintext mock listening on :8080 POST /v1/services POST /v1/config") + log.Fatal(http.ListenAndServe(":8080", nil)) +} From 118d11c1940dd316503b287263288383a79691ac Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 18:05:17 +0300 Subject: [PATCH 08/13] fix cmake --- client/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 16ab21dde..b4fa32982 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -34,7 +34,7 @@ add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}") add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}") if(AMNEZIA_LOCAL_GATEWAY) - add_definitions(-DAMNEZIA_LOCAL_GATEWAY="$ENV{AMNEZIA_LOCAL_GATEWAY}") + add_definitions(-DAMNEZIA_LOCAL_GATEWAY=1) endif() if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID)) From e5bbfe00369a07a3cf29c65b717adf306e87d51d Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Tue, 28 Apr 2026 15:16:45 +0300 Subject: [PATCH 09/13] add CAPTCHA refreshed --- client/core/api/apiUtils.cpp | 3 ++ client/core/controllers/gatewayController.cpp | 2 +- client/core/defs.h | 3 +- client/core/errorstrings.cpp | 1 + .../controllers/api/apiConfigsController.cpp | 36 +++++++++++++++++++ .../ui/controllers/api/apiConfigsController.h | 1 + client/ui/qml/Controls2/CaptchaDialogType.qml | 21 +++++++++++ client/ui/qml/main2.qml | 4 +++ tools/local_gateway/main.go | 20 +++++++++++ 9 files changed, 89 insertions(+), 2 deletions(-) diff --git a/client/core/api/apiUtils.cpp b/client/core/api/apiUtils.cpp index c1475b5c7..e5ec164ec 100644 --- a/client/core/api/apiUtils.cpp +++ b/client/core/api/apiUtils.cpp @@ -201,6 +201,9 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl } if (status == httpStatusCodePaymentRequired) { const QString errorToken = apiErrorTokenFromJson(jsonObj); + if (errorToken.contains(QLatin1String("refresh_captcha"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaRefreshError; + } if (errorToken.contains(QLatin1String("invalid_captcha"), Qt::CaseInsensitive)) { return amnezia::ErrorCode::ApiCaptchaInvalidError; } diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 9ec361882..9954079b5 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -482,7 +482,7 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep return false; } const QString err = jsonObj.value(QStringLiteral("error")).toString(); - if (err.contains(QLatin1String("captcha"), Qt::CaseInsensitive) || err == QLatin1String("rate_limit_exceeded")) { + if (err.contains(QLatin1String("captcha"), Qt::CaseInsensitive) || err == QLatin1String("rate_limit_exceeded") || err == QLatin1String("refresh_captcha")) { return false; } } diff --git a/client/core/defs.h b/client/core/defs.h index c343affb2..69c06e16b 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -128,7 +128,8 @@ namespace amnezia ApiTrialAlreadyUsedError = 1116, ApiCaptchaRequiredError = 1117, ApiCaptchaInvalidError = 1118, - ApiRateLimitError = 1119, + ApiCaptchaRefreshError = 1119, + ApiRateLimitError = 1120, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index c024f5028..e62fc7e23 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -85,6 +85,7 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break; case (ErrorCode::ApiCaptchaRequiredError): errorMessage = QObject::tr("CAPTCHA verification is required"); break; case (ErrorCode::ApiCaptchaInvalidError): errorMessage = QObject::tr("CAPTCHA was incorrect. Please try again"); break; + case (ErrorCode::ApiCaptchaRefreshError): errorMessage = QObject::tr("CAPTCHA refreshed. Please try again"); break; case (ErrorCode::ApiRateLimitError): errorMessage = QObject::tr("Too many requests. Please try again later"); break; // QFile errors diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index 3d82c779c..8115aaa60 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -1371,3 +1371,39 @@ void ApiConfigsController::onCaptchaSolved(const QString &captchaId, const QStri m_serversModel->addServer(serverConfig); emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); } + +void ApiConfigsController::onRefreshCaptchaRequested() { + if (!m_captchaState.isPending) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + QJsonObject apiPayload = m_captchaState.apiPayload; + apiPayload.insert("refresh_captcha", true); + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(m_captchaState.endpoint, apiPayload, responseBody); + + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return; + } + + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (!jsonDoc.isObject()) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + QJsonObject jsonObj = jsonDoc.object(); + + QString newCaptchaId = jsonObj.value("captcha_id").toString(); + QString newCaptchaImage = jsonObj.value("captcha_image").toString(); + QString hint = jsonObj.value("hint").toString(tr("CAPTCHA refreshed")); + + // Обновляем state (важно!) + m_captchaState.apiPayload = apiPayload; + m_captchaState.isPending = true; + + emit captchaRequired(newCaptchaId, newCaptchaImage, hint); +} diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index f732d7cc3..1eb7c1efa 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -50,6 +50,7 @@ public slots: bool isVlessProtocol(); void onCaptchaSolved(const QString &captchaId, const QString &solution); + void onRefreshCaptchaRequested(); signals: void errorOccurred(ErrorCode errorCode); diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml index 5468c653a..e8c07cdb8 100644 --- a/client/ui/qml/Controls2/CaptchaDialogType.qml +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -15,6 +15,7 @@ Popup { property string hint: "Please solve the CAPTCHA to continue" signal captchaSolved(string captchaId, string solution) + signal refreshCaptchaRequested() leftMargin: 25 rightMargin: 25 @@ -118,6 +119,26 @@ Popup { } } + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ParagraphTextType { + text: qsTr("Can't read the image?") + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + } + + BasicButtonType { + text: qsTr("Refresh") + implicitHeight: 32 + + onClicked: { + root.refreshCaptchaRequested() + } + } + } + ParagraphTextType { text: qsTr("Enter the numbers from the image:") Layout.fillWidth: true diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index b42b1a6b1..da0403469 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -213,6 +213,10 @@ Window { captchaDialog.close() ApiConfigsController.onCaptchaSolved(captchaId, solution) } + + onRefreshCaptchaRequested: function() { + ApiConfigsController.onRefreshCaptchaRequested() + } } } diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go index 49da64f0f..661e9a909 100644 --- a/tools/local_gateway/main.go +++ b/tools/local_gateway/main.go @@ -104,6 +104,26 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { captchaID, _ := body["captcha_id"].(string) solution, _ := body["captcha_solution"].(string) + refresh, _ := body["refresh_captcha"].(bool) + + if refresh { + var buf bytes.Buffer + id := captcha.NewLen(6) + _ = captcha.WriteImage(&buf, id, 240, 80) + b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + + log.Printf("captcha REFRESH id=%s uuid=%s", shortID(id), uuid) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + _ = json.NewEncoder(w).Encode(map[string]string{ + "captcha_id": id, + "captcha_image": b64, + "hint": "Refreshed CAPTCHA", + }) + return + } if captchaID != "" && solution != "" { if captcha.VerifyString(captchaID, solution) { From 63cc1e6a8e2fe9a7bd422f881353790c0d14786a Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 30 Apr 2026 12:26:41 +0300 Subject: [PATCH 10/13] fixed captcha --- .../api/subscriptionController.cpp | 69 ++++++++++++++- .../controllers/api/subscriptionController.h | 15 +++- .../secureAppSettingsRepository.cpp | 8 ++ .../api/subscriptionUiController.cpp | 85 ++++++++++++++++++- .../api/subscriptionUiController.h | 17 ++++ .../qml/Pages2/PageSetupWizardApiFreeInfo.qml | 2 +- client/ui/qml/main2.qml | 4 +- client/ui/qml/qml.qrc | 1 + 8 files changed, 192 insertions(+), 9 deletions(-) diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index e71abbcaf..5f6b5e1c5 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -218,7 +218,8 @@ ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const ProtocolData &protocolData, - ServerConfig &serverConfig) + ServerConfig &serverConfig, + CaptchaInfo &captchaInfo) { GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -235,6 +236,19 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo QByteArray responseBody; ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + + if (errorCode == ErrorCode::ApiCaptchaRequiredError) { + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + QJsonObject jsonObj = jsonDoc.object(); + captchaInfo.captchaId = jsonObj.value("captcha_id").toString(); + captchaInfo.captchaImageBase64 = jsonObj.value("captcha_image").toString(); + captchaInfo.hint = jsonObj.value("hint").toString(); + captchaInfo.isRequired = true; + } + return errorCode; + } + if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -244,11 +258,11 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo if (errorCode != ErrorCode::NoError) { return errorCode; } - + updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody); - + ServerConfig serverConfigModel = ServerConfig::fromJson(serverConfigJson); - + if (!serverConfigModel.isApiV2()) { return ErrorCode::InternalError; } @@ -1092,3 +1106,50 @@ QFuture> SubscriptionController::getRenewalLink(int se return promise->future(); } +ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &userCountryCode, + const QString &serviceType, + const QString &serviceProtocol, + const ProtocolData &protocolData, + const QString &captchaId, + const QString &captchaSolution, + ServerConfig &serverConfig) { + GatewayRequestData gatewayRequestData{QSysInfo::productType(), + QString(APP_VERSION), + m_appSettingsRepository->getAppLanguage().name().split("_").first(), + m_appSettingsRepository->getInstallationUuid(true), + userCountryCode, + "", + serviceType, + serviceProtocol, + QJsonObject()}; + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload); + + apiPayload["captcha_id"] = captchaId; + apiPayload["captcha_solution"] = captchaSolution; + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + return errorCode; + } + + QJsonObject serverConfigJson; + errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson); + if (errorCode != ErrorCode::NoError) { + return errorCode; + } + + updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody); + + ServerConfig serverConfigModel = ServerConfig::fromJson(serverConfigJson); + + if (!serverConfigModel.isApiV2()) { + return ErrorCode::InternalError; + } + + m_serversRepository->addServer(serverConfigModel); + serverConfig = serverConfigModel; + return ErrorCode::NoError; +} diff --git a/client/core/controllers/api/subscriptionController.h b/client/core/controllers/api/subscriptionController.h index 05ec80a5b..85f4d47a9 100644 --- a/client/core/controllers/api/subscriptionController.h +++ b/client/core/controllers/api/subscriptionController.h @@ -43,6 +43,13 @@ public: QJsonObject toJsonObject() const; }; + struct CaptchaInfo { + QString captchaId; + QString captchaImageBase64; + QString hint; + bool isRequired = false; + }; + explicit SubscriptionController(SecureServersRepository* serversRepository, SecureAppSettingsRepository* appSettingsRepository); @@ -52,7 +59,8 @@ public: ErrorCode importServiceFromGateway(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const ProtocolData &protocolData, - ServerConfig &serverConfig); + ServerConfig &serverConfig, + CaptchaInfo &captchaInfo); ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const QString &email, ServerConfig &serverConfig); @@ -104,6 +112,11 @@ public: AppStoreRestoreResult processAppStoreRestore(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol); + ErrorCode resolveImportServiceCaptcha(const QString &userCountryCode, const QString &serviceType, + const QString &serviceProtocol, const ProtocolData &protocolData, + const QString &captchaId, const QString &captchaSolution, + ServerConfig &serverConfig); + private: ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false); bool isApiKeyExpired(int serverIndex) const; diff --git a/client/core/repositories/secureAppSettingsRepository.cpp b/client/core/repositories/secureAppSettingsRepository.cpp index 3bc579c74..ca604ef9b 100644 --- a/client/core/repositories/secureAppSettingsRepository.cpp +++ b/client/core/repositories/secureAppSettingsRepository.cpp @@ -16,7 +16,11 @@ using namespace amnezia; namespace { +#ifdef AMNEZIA_LOCAL_GATEWAY + constexpr char gatewayEndpoint[] = "http://localhost:8080/"; +#else constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/"; +#endif } SecureAppSettingsRepository::SecureAppSettingsRepository(SecureQSettings* settings, QObject *parent) @@ -260,7 +264,11 @@ void SecureAppSettingsRepository::setGatewayEndpoint(const QString &endpoint) void SecureAppSettingsRepository::resetGatewayEndpoint() { m_gatewayEndpoint = gatewayEndpoint; +#ifdef AMNEZIA_LOCAL_GATEWAY + setValue(QStringLiteral("Conf/devGatewayEnv"), true); +#else setValue("Conf/gatewayEndpoint", gatewayEndpoint); +#endif } void SecureAppSettingsRepository::setDevGatewayEndpoint() diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 370599a24..aa6934ddb 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -76,6 +76,11 @@ SubscriptionUiController::SubscriptionUiController(ServersController* serversCon }); } +bool SubscriptionUiController::isCaptchaAwaitingUser() const +{ + return m_captchaState.isPending; +} + bool SubscriptionUiController::exportVpnKey(int serverIndex, const QString &fileName) { if (fileName.isEmpty()) { @@ -260,21 +265,99 @@ bool SubscriptionUiController::importFreeFromGateway() } SubscriptionController::ProtocolData protocolData = m_subscriptionController->generateProtocolData(serviceProtocol); + SubscriptionController::CaptchaInfo captchaInfo; ServerConfig serverConfig; ErrorCode errorCode = m_subscriptionController->importServiceFromGateway(userCountryCode, serviceType, serviceProtocol, protocolData, - serverConfig); + serverConfig, captchaInfo); if (errorCode == ErrorCode::NoError) { emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); return true; + } else if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { + m_captchaState.userCountryCode = userCountryCode; + m_captchaState.serviceType = serviceType; + m_captchaState.serviceProtocol = serviceProtocol; + m_captchaState.openvpnPrivKey = protocolData.certPrivKey; + m_captchaState.wireguardClientPrivKey = protocolData.wireGuardClientPrivKey; + m_captchaState.wireguardClientPubKey = protocolData.wireGuardClientPubKey; + m_captchaState.xrayUuid = protocolData.xrayUuid; + m_captchaState.isPending = true; + + emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, + captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + return false; } else { emit errorOccurred(errorCode); return false; } } +void SubscriptionUiController::onCaptchaSolved(const QString &captchaId, const QString &solution) +{ + if (!m_captchaState.isPending) { + return; + } + + SubscriptionController::ProtocolData protocolData; + protocolData.certPrivKey = m_captchaState.openvpnPrivKey; + protocolData.wireGuardClientPrivKey = m_captchaState.wireguardClientPrivKey; + protocolData.wireGuardClientPubKey = m_captchaState.wireguardClientPubKey; + protocolData.xrayUuid = m_captchaState.xrayUuid; + + ServerConfig serverConfig; + ErrorCode errorCode = m_subscriptionController->resolveImportServiceCaptcha( + m_captchaState.userCountryCode, + m_captchaState.serviceType, + m_captchaState.serviceProtocol, + protocolData, + captchaId, + solution, + serverConfig); + + m_captchaState.isPending = false; + + if (errorCode == ErrorCode::NoError) { + m_serversController->addServer(serverConfig); + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); + } else { + emit errorOccurred(errorCode); + } +} + +void SubscriptionUiController::onRefreshCaptchaRequested() +{ + if (!m_captchaState.isPending) { + return; + } + + SubscriptionController::ProtocolData protocolData; + protocolData.certPrivKey = m_captchaState.openvpnPrivKey; + protocolData.wireGuardClientPrivKey = m_captchaState.wireguardClientPrivKey; + protocolData.wireGuardClientPubKey = m_captchaState.wireguardClientPubKey; + protocolData.xrayUuid = m_captchaState.xrayUuid; + + SubscriptionController::CaptchaInfo captchaInfo; + ServerConfig serverConfig; + + ErrorCode errorCode = m_subscriptionController->importServiceFromGateway( + m_captchaState.userCountryCode, + m_captchaState.serviceType, + m_captchaState.serviceProtocol, + protocolData, + serverConfig, + captchaInfo); + + if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { + emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, + captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + } else if (errorCode != ErrorCode::NoError) { + m_captchaState.isPending = false; + emit errorOccurred(errorCode); + } +} + bool SubscriptionUiController::importTrialFromGateway(const QString &email) { emit trialEmailError(QString()); diff --git a/client/ui/controllers/api/subscriptionUiController.h b/client/ui/controllers/api/subscriptionUiController.h index 73e37593f..142335991 100644 --- a/client/ui/controllers/api/subscriptionUiController.h +++ b/client/ui/controllers/api/subscriptionUiController.h @@ -56,6 +56,10 @@ public slots: void setCurrentProtocol(int serverIndex, const QString &protocolName); bool isVlessProtocol(int serverIndex); + bool isCaptchaAwaitingUser() const; + void onCaptchaSolved(const QString &captchaId, const QString &solution); + void onRefreshCaptchaRequested(); + void removeApiConfig(int serverIndex); bool getAccountInfo(int serverIndex, bool reload); @@ -79,6 +83,19 @@ signals: void apiConfigRemoved(const QString &message); void vpnKeyExportReady(); + void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint); + +private: + struct CaptchaState { + QString userCountryCode; + QString serviceType; + QString serviceProtocol; + QString openvpnPrivKey; + QString wireguardClientPrivKey; + QString wireguardClientPubKey; + QString xrayUuid; + bool isPending = false; + } m_captchaState; private: QList getQrCodes(); diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml index 7e89f18c0..af974915d 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml @@ -130,7 +130,7 @@ PageType { PageController.showBusyIndicator(false) if (!result) { - if (ApiConfigsController.isCaptchaAwaitingUser()) { + if (SubscriptionUiController.isCaptchaAwaitingUser()) { return } var endpoint = ApiServicesModel.getStoreEndpoint() diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 65111d180..0ad23f929 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -211,11 +211,11 @@ Window { onCaptchaSolved: function(captchaId, solution) { captchaDialog.close() - ApiConfigsController.onCaptchaSolved(captchaId, solution) + SubscriptionUiController.onCaptchaSolved(captchaId, solution) } onRefreshCaptchaRequested: function() { - ApiConfigsController.onRefreshCaptchaRequested() + SubscriptionUiController.onRefreshCaptchaRequested() } } } diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index 9f2a8ccf6..18957481b 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -23,6 +23,7 @@ Controls2/BackButtonType.qml Controls2/BasicButtonType.qml Controls2/BusyIndicatorType.qml + Controls2/CaptchaDialogType.qml Controls2/CardType.qml Controls2/CardWithIconsType.qml Controls2/CheckBoxType.qml From 344e7106c9c318b0d3a6fb7ac0dc84c3743a176c Mon Sep 17 00:00:00 2001 From: dranik Date: Mon, 4 May 2026 13:20:07 +0300 Subject: [PATCH 11/13] update QML Captha --- .../api/subscriptionUiController.cpp | 4 +- client/ui/qml/Controls2/CaptchaDialogType.qml | 254 ++++++++++-------- tools/local_gateway/README.md | 55 ++++ tools/local_gateway/main.go | 2 +- 4 files changed, 207 insertions(+), 108 deletions(-) create mode 100644 tools/local_gateway/README.md diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 955301163..123aa8d8a 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -286,7 +286,7 @@ bool SubscriptionUiController::importFreeFromGateway() m_captchaState.isPending = true; emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, - captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + captchaInfo.hint.isEmpty() ? tr("Enter the digits from the image to continue") : captchaInfo.hint); return false; } else { emit errorOccurred(errorCode); @@ -351,7 +351,7 @@ void SubscriptionUiController::onRefreshCaptchaRequested() if (errorCode == ErrorCode::ApiCaptchaRequiredError && captchaInfo.isRequired) { emit captchaRequired(captchaInfo.captchaId, captchaInfo.captchaImageBase64, - captchaInfo.hint.isEmpty() ? tr("Please solve the CAPTCHA to continue") : captchaInfo.hint); + captchaInfo.hint.isEmpty() ? tr("Enter the digits from the image to continue") : captchaInfo.hint); } else if (errorCode != ErrorCode::NoError) { m_captchaState.isPending = false; emit errorOccurred(errorCode); diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml index e8c07cdb8..35cf981e2 100644 --- a/client/ui/qml/Controls2/CaptchaDialogType.qml +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -1,9 +1,12 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Window +import Qt5Compat.GraphicalEffects import Style 1.0 +import "." import "TextTypes" import "../Config" @@ -12,7 +15,7 @@ Popup { property string captchaId property string captchaImageBase64 - property string hint: "Please solve the CAPTCHA to continue" + property string hint: qsTr("Enter the digits from the image to continue") signal captchaSolved(string captchaId, string solution) signal refreshCaptchaRequested() @@ -33,8 +36,8 @@ Popup { onOpened: { timer.start() - solutionInput.text = "" - solutionInput.focus = true + solutionField.textField.text = "" + solutionField.textField.focus = true } onClosed: { @@ -43,8 +46,8 @@ Popup { background: Rectangle { anchors.fill: parent - color: "white" - radius: 4 + color: AmneziaStyle.color.slateGray + radius: 22 } Timer { @@ -52,7 +55,7 @@ Popup { interval: 200 onTriggered: { FocusController.pushRootObject(root) - FocusController.setFocusItem(solutionInput) + FocusController.setFocusItem(solutionField.textField) } repeat: false running: true @@ -68,140 +71,181 @@ Popup { id: contentLayout anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - anchors.topMargin: 16 - anchors.bottomMargin: 16 + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + anchors.bottomMargin: 20 - spacing: 12 + spacing: 16 + + Text { + id: titleText - CaptionTextType { - text: qsTr("CAPTCHA Verification") Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } + Layout.alignment: Qt.AlignLeft | Qt.AlignTop - ParagraphTextType { - text: hint - Layout.fillWidth: true + text: root.hint wrapMode: Text.WordWrap + color: AmneziaStyle.color.paleGray + font.pixelSize: 18 + font.weight: Font.Bold + font.family: "PT Root UI VF" + lineHeight: 24 + LanguageUiController.getLineHeightAppend() + lineHeightMode: Text.FixedHeight horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignTop } - Rectangle { + Item { Layout.fillWidth: true Layout.preferredHeight: 200 - color: AmneziaStyle.color.lightGray - radius: 4 - Image { - id: captchaImage - anchors.centerIn: parent - cache: false + Rectangle { + id: imagePanel - Component.onCompleted: { - if (captchaImageBase64 !== "") { - source = "data:image/png;base64," + captchaImageBase64 + anchors.fill: parent + color: AmneziaStyle.color.pearlGray + radius: 16 + + Image { + id: captchaImage + + anchors.centerIn: parent + fillMode: Image.PreserveAspectFit + cache: false + + Component.onCompleted: { + if (captchaImageBase64 !== "") { + source = "data:image/png;base64," + captchaImageBase64 + } + } + + Connections { + target: root + function onCaptchaImageBase64Changed() { + captchaImage.source = "data:image/png;base64," + root.captchaImageBase64 + } } } - Connections { - target: root - function onCaptchaImageBase64Changed() { - captchaImage.source = "data:image/png;base64," + root.captchaImageBase64 + BusyIndicator { + anchors.centerIn: parent + running: captchaImage.status === Image.Loading + } + + Rectangle { + id: refreshHit + + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 10 + width: 44 + height: 44 + radius: width / 2 + color: AmneziaStyle.color.charcoalGray + + Image { + id: refreshIcon + + anchors.centerIn: parent + width: 26 + height: 26 + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + antialiasing: true + source: "qrc:/images/controls/refresh-cw.svg" + // Rasterize SVG at high resolution, then scale down — avoids blocky edges on HiDPI. + readonly property real _dpr: (Window.window && Window.window.screen) + ? Window.window.screen.devicePixelRatio : 2.0 + readonly property int _raster: Math.ceil(64 * Math.min(Math.max(_dpr, 1.0), 4.0)) + sourceSize: Qt.size(_raster, _raster) + + layer.enabled: true + layer.smooth: true + layer.textureSize: Qt.size(_raster, _raster) + layer.effect: ColorOverlay { + color: AmneziaStyle.color.goldenApricot + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.refreshCaptchaRequested() } } } + } - BusyIndicator { - anchors.centerIn: parent - running: captchaImage.status === Image.Loading + TextFieldWithHeaderType { + id: solutionField + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + + headerText: qsTr("Digits from the image") + headerTextColor: AmneziaStyle.color.mutedGray + + textField.placeholderText: qsTr("_ _ _ _ _ _") + textField.placeholderTextColor: AmneziaStyle.color.mutedGray + textField.inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhNoPredictiveText + textField.maximumLength: 6 + textField.font.letterSpacing: 2 + + textField.onAccepted: { + submitIfNonEmpty() } } - RowLayout { + Item { Layout.fillWidth: true - spacing: 8 + Layout.preferredHeight: 8 + } - ParagraphTextType { - text: qsTr("Can't read the image?") - Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } + BasicButtonType { + id: continueButton - BasicButtonType { - text: qsTr("Refresh") - implicitHeight: 32 + Layout.fillWidth: true + implicitHeight: 52 - onClicked: { - root.refreshCaptchaRequested() - } + text: qsTr("Continue") + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + submitIfNonEmpty() } } - ParagraphTextType { - text: qsTr("Enter the numbers from the image:") - Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - } - - TextField { - id: solutionInput + BasicButtonType { + id: closeButton Layout.fillWidth: true - implicitHeight: 40 + implicitHeight: 52 - placeholderText: qsTr("Enter CAPTCHA solution") + text: qsTr("Close") + defaultColor: AmneziaStyle.color.transparent + hoveredColor: AmneziaStyle.color.translucentWhite + pressedColor: AmneziaStyle.color.sheerWhite + textColor: AmneziaStyle.color.paleGray + borderWidth: 1 + borderColor: AmneziaStyle.color.mutedGray + borderFocusedColor: AmneziaStyle.color.paleGray - background: Rectangle { - border.color: AmneziaStyle.color.charcoalGray - border.width: 1 - radius: 4 - color: "white" - } - - onAccepted: { - if (solutionInput.text.trim() !== "") { - root.captchaSolved(root.captchaId, solutionInput.text.trim()) - } - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - BasicButtonType { - id: submitButton - - Layout.fillWidth: true - implicitHeight: 40 - - text: qsTr("Submit") - - onClicked: { - if (solutionInput.text.trim() !== "") { - root.captchaSolved(root.captchaId, solutionInput.text.trim()) - } - } - } - - BasicButtonType { - id: cancelButton - - Layout.fillWidth: true - implicitHeight: 40 - - text: qsTr("Cancel") - defaultColor: AmneziaStyle.color.lightGray - hoveredColor: AmneziaStyle.color.charcoalGray - textColor: AmneziaStyle.color.midnightBlack - - onClicked: { - root.close() - } + clickedFunc: function() { + root.close() } } } } + + function submitIfNonEmpty() { + const t = solutionField.textField.text.trim() + if (t !== "") { + root.captchaSolved(root.captchaId, t) + } + } } diff --git a/tools/local_gateway/README.md b/tools/local_gateway/README.md new file mode 100644 index 000000000..1ca566012 --- /dev/null +++ b/tools/local_gateway/README.md @@ -0,0 +1,55 @@ +# Local gateway (plaintext mock) + +Минимальный HTTP-сервер на Go, который имитирует ответы Amnezia API gateway **без шифрования**: те же JSON-тела, что клиент отправляет в зашифрованном виде на прод. Удобно для отладки UI (в том числе CAPTCHA) и сценария **Amnezia Free**. + +## Требования + +- [Go](https://go.dev/dl/) **1.21** или новее (см. `go.mod`). + +## Запуск + +Из каталога `tools/local_gateway`: + +```bash +cd tools/local_gateway +go mod download +go run . +``` + +Сервер слушает **`http://127.0.0.1:8080`** (в коде задано явно). + +В логах должно появиться сообщение вида: + +`plaintext mock listening on :8080 POST /v1/services POST /v1/config` + +## Эндпоинты + +| Метод | Путь | Назначение | +|--------|------|------------| +| `POST` | `/v1/services` | Минимальный ответ со списком сервисов (в т.ч. `amnezia-free` / `awg`). | +| `POST` | `/v1/config` | Импорт конфига: лимит/CAPTCHA (`dchest/captcha`), проверка решения, мок-ответы. | + +Других маршрутов нет. + +## Связка с клиентом AmneziaVPN + +1. Соберите клиент с флагом CMake **`AMNEZIA_LOCAL_GATEWAY=ON`** — тогда для `localhost` запросы к gateway уходят **plaintext JSON** без RSA/AES (см. `GatewayController`, `SecureAppSettingsRepository`). +2. В настройках приложения endpoint gateway должен указывать на **`http://localhost:8080/`** (или `http://127.0.0.1:8080/`). При включённом `AMNEZIA_LOCAL_GATEWAY` дефолтный URL в коде уже `http://localhost:8080/`. + +После этого сценарии вроде **Amnezia Free → Continue** будут ходить в этот mock. + +## Поведение CAPTCHA (для разработчика) + +В `main.go` константа **`rateLimitExcessAfter`**: при `0` «лимит» срабатывает сразу и первый запрос к `/v1/config` для `amnezia-free` чаще возвращает ответ с CAPTCHA; большее значение имитирует N успешных запросов до CAPTCHA. + +Опционально в теле `POST /v1/config` mock обрабатывает **`refresh_captcha": true`** (отдельная ветка в коде); кнопка «Обновить» в клиенте может повторно вызывать обычный импорт без этого поля — смотрите актуальную логику в `SubscriptionUiController`. + +## Зависимости + +- `github.com/dchest/captcha` — генерация и проверка картинки CAPTCHA. + +После изменения зависимостей: + +```bash +go mod tidy +``` diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go index 661e9a909..35905aa19 100644 --- a/tools/local_gateway/main.go +++ b/tools/local_gateway/main.go @@ -174,7 +174,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { "error": "rate_limit_exceeded", "captcha_id": id, "captcha_image": b64, - "hint": "Please solve the CAPTCHA to continue", + "hint": "Enter the digits from the image to continue", }) return } From d3c1f0a6f8a9bcaed73e35ed5d1fc86e1ce34be8 Mon Sep 17 00:00:00 2001 From: dranik Date: Tue, 5 May 2026 16:45:26 +0300 Subject: [PATCH 12/13] fixed crash app & up vercion & fix qml captha --- CMakeLists.txt | 2 +- .../api/subscriptionController.cpp | 59 ++++++++++++++++--- .../controllers/api/subscriptionController.h | 10 +++- client/core/controllers/gatewayController.cpp | 41 +++++++++++-- client/core/controllers/gatewayController.h | 13 ++-- client/core/controllers/updateController.cpp | 4 ++ client/core/controllers/updateController.h | 7 +++ client/core/utils/api/apiUtils.cpp | 7 +++ .../api/subscriptionUiController.cpp | 25 ++++++-- .../api/subscriptionUiController.h | 1 + client/ui/qml/Controls2/CaptchaDialogType.qml | 12 ++++ client/ui/qml/main2.qml | 5 +- 12 files changed, 159 insertions(+), 27 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 11400bf73..8b182af6d 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.1.1) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index ab0840a97..58d6758b2 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -209,11 +210,19 @@ void SubscriptionController::updateApiConfigInJson(QJsonObject &serverConfigJson serverConfigJson[apiDefs::key::apiConfig] = apiConfig; } -ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase) +ErrorCode SubscriptionController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, + bool isTestPurchase, QString *outEffectiveRequestBase, + const QString &reuseRequestBase) { - GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs, - m_appSettingsRepository->isStrictKillSwitchEnabled()); - return gatewayController.post(endpoint, apiPayload, responseBody); + GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs, + m_appSettingsRepository->isStrictKillSwitchEnabled(), nullptr, reuseRequestBase); + return gatewayController.post(endpoint, apiPayload, responseBody, outEffectiveRequestBase); +} + +void SubscriptionController::clearGatewayCaptchaSticky() +{ + m_gatewayCaptchaStickyBase.clear(); } ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCountryCode, const QString &serviceType, @@ -235,9 +244,12 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload); QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + QString effectiveRequestBase; + ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, false, &effectiveRequestBase, + m_gatewayCaptchaStickyBase); if (errorCode == ErrorCode::ApiCaptchaRequiredError) { + m_gatewayCaptchaStickyBase = effectiveRequestBase; QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); @@ -249,6 +261,8 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo return errorCode; } + m_gatewayCaptchaStickyBase.clear(); + if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -1110,7 +1124,8 @@ ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &use const ProtocolData &protocolData, const QString &captchaId, const QString &captchaSolution, - ServerConfig &serverConfig) { + ServerConfig &serverConfig, + CaptchaInfo *retryCaptchaOut) { GatewayRequestData gatewayRequestData{QSysInfo::productType(), QString(APP_VERSION), m_appSettingsRepository->getAppLanguage().name().split("_").first(), @@ -1125,14 +1140,42 @@ ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &use appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload); apiPayload["captcha_id"] = captchaId; - apiPayload["captcha_solution"] = captchaSolution; + QString normalizedSolution; + normalizedSolution.reserve(captchaSolution.size()); + for (const QChar &ch : captchaSolution) { + const ushort u = ch.unicode(); + if (u >= '0' && u <= '9') { + normalizedSolution += ch; + } else if (u >= 0xFF10 && u <= 0xFF19) { + normalizedSolution += QChar(static_cast(u - 0xFF10 + '0')); + } + } + apiPayload["captcha_solution"] = normalizedSolution.isEmpty() ? captchaSolution.trimmed() : normalizedSolution; QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + QString effectiveRequestBase; + ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, false, &effectiveRequestBase, + m_gatewayCaptchaStickyBase); if (errorCode != ErrorCode::NoError) { + m_gatewayCaptchaStickyBase = effectiveRequestBase; + if (retryCaptchaOut + && (errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError)) { + const QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); + if (jsonDoc.isObject()) { + const QJsonObject jsonObj = jsonDoc.object(); + if (jsonObj.contains(QStringLiteral("captcha_id")) && jsonObj.contains(QStringLiteral("captcha_image"))) { + retryCaptchaOut->captchaId = jsonObj.value(QStringLiteral("captcha_id")).toString(); + retryCaptchaOut->captchaImageBase64 = jsonObj.value(QStringLiteral("captcha_image")).toString(); + retryCaptchaOut->hint = jsonObj.value(QStringLiteral("hint")).toString(); + retryCaptchaOut->isRequired = true; + } + } + } return errorCode; } + m_gatewayCaptchaStickyBase.clear(); + QJsonObject serverConfigJson; errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson); if (errorCode != ErrorCode::NoError) { diff --git a/client/core/controllers/api/subscriptionController.h b/client/core/controllers/api/subscriptionController.h index 172708544..f2ec3c54b 100644 --- a/client/core/controllers/api/subscriptionController.h +++ b/client/core/controllers/api/subscriptionController.h @@ -115,10 +115,14 @@ public: ErrorCode resolveImportServiceCaptcha(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol, const ProtocolData &protocolData, const QString &captchaId, const QString &captchaSolution, - ServerConfig &serverConfig); + ServerConfig &serverConfig, CaptchaInfo *retryCaptchaOut = nullptr); + + void clearGatewayCaptchaSticky(); private: - ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false); + ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, + bool isTestPurchase = false, QString *outEffectiveRequestBase = nullptr, + const QString &reuseRequestBase = QString()); bool isApiKeyExpired(int serverIndex) const; ErrorCode extractServerConfigJsonFromResponse(const QByteArray &apiResponseBody, const QString &protocol, @@ -129,6 +133,8 @@ private: SecureServersRepository* m_serversRepository; SecureAppSettingsRepository* m_appSettingsRepository; + + QString m_gatewayCaptchaStickyBase; }; #endif // SUBSCRIPTIONCONTROLLER_H diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index f2b016135..4ef56c66b 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -56,13 +56,23 @@ namespace } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, - const bool isStrictKillSwitchEnabled, QObject *parent) + const bool isStrictKillSwitchEnabled, QObject *parent, const QString &reuseAgwRequestBase) : QObject(parent), m_gatewayEndpoint(gatewayEndpoint), m_isDevEnvironment(isDevEnvironment), m_requestTimeoutMsecs(requestTimeoutMsecs), m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled) { + if (!reuseAgwRequestBase.isEmpty()) { + m_proxyUrl = reuseAgwRequestBase; + } +} + +void GatewayController::writeEffectiveRequestBase(QString *outEffectiveRequestBase) const +{ + if (outEffectiveRequestBase) { + *outEffectiveRequestBase = m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl; + } } GatewayController::EncryptedRequestData GatewayController::prepareRequest(const QString &endpoint, const QJsonObject &apiPayload) @@ -172,10 +182,12 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co return result; } -ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody) +ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody, + QString *outEffectiveRequestBase) { EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload); if (encRequestData.errorCode != ErrorCode::NoError) { + writeEffectiveRequestBase(outEffectiveRequestBase); return encRequestData.errorCode; } @@ -206,7 +218,8 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); #endif - if (!plaintextMock && sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (!plaintextMock && sslErrors.isEmpty() + && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) { auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) { encRequestData.request.setUrl(url); return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); @@ -223,7 +236,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt); if (!sslErrors.isEmpty() - || shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + || shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) { sslErrors = nestedSslErrors; return false; } @@ -239,14 +252,17 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api const auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, responseBody); if (errorCode) { + writeEffectiveRequestBase(outEffectiveRequestBase); return errorCode; } if (!decryptionResult.isDecryptionSuccessful) { qCritical() << "error when decrypting the request body"; + writeEffectiveRequestBase(outEffectiveRequestBase); return ErrorCode::ApiConfigDecryptionError; } + writeEffectiveRequestBase(outEffectiveRequestBase); return ErrorCode::NoError; } @@ -312,7 +328,8 @@ QFuture> GatewayController::postAsync(const QString promise->finish(); }; - if (!plaintextMock && sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { + if (!plaintextMock && sslErrors->isEmpty() + && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, httpStatusCode)) { auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); @@ -471,7 +488,7 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS } bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, - bool isDecryptionSuccessful) + bool isDecryptionSuccessful, int httpStatusCode) { const QByteArray &responseBody = decryptedResponseBody; @@ -497,6 +514,10 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep return false; } } + // Reverse proxy or unknown route returns plaintext (e.g. "404 page not found") — not a proxy/CDN issue. + if (httpStatusCode == httpStatusCodeNotFound || replyError == QNetworkReply::ContentNotFoundError) { + return false; + } qDebug() << "failed to decrypt the data"; return true; } @@ -562,6 +583,10 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv qDebug() << "go to the next proxy endpoint"; QNetworkReply *reply = requestFunction(endpoint.arg(proxyUrl)); + if (!reply) { + qWarning() << "GatewayController::bypassProxy: requestFunction returned null"; + return false; + } QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); @@ -584,6 +609,10 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv for (const QString &proxyUrl : proxyUrls) { request.setUrl(proxyUrl + "lmbd-health"); reply = amnApp->networkManager()->get(request); + if (!reply) { + qWarning() << "GatewayController::bypassProxy: health check get() returned null"; + continue; + } connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); diff --git a/client/core/controllers/gatewayController.h b/client/core/controllers/gatewayController.h index ef2994709..28bf47383 100644 --- a/client/core/controllers/gatewayController.h +++ b/client/core/controllers/gatewayController.h @@ -22,9 +22,11 @@ class GatewayController : public QObject public: explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, - const bool isStrictKillSwitchEnabled, QObject *parent = nullptr); + const bool isStrictKillSwitchEnabled, QObject *parent = nullptr, + const QString &reuseAgwRequestBase = QString()); - amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody); + amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody, + QString *outEffectiveRequestBase = nullptr); QFuture> postAsync(const QString &endpoint, const QJsonObject apiPayload); private: @@ -49,7 +51,8 @@ private: const QByteArray &key, const QByteArray &iv, const QByteArray &salt); QStringList getProxyUrls(const QString &serviceType, const QString &userCountryCode); - bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful); + bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, + bool isDecryptionSuccessful, int httpStatusCode); void bypassProxy(const QString &endpoint, const QString &serviceType, const QString &userCountryCode, std::function requestFunction, std::function &sslErrors)> replyProcessingFunction); @@ -61,12 +64,14 @@ private: const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData, std::function &, QNetworkReply::NetworkError, const QString &, int)> onComplete); + void writeEffectiveRequestBase(QString *outEffectiveRequestBase) const; + int m_requestTimeoutMsecs; QString m_gatewayEndpoint; bool m_isDevEnvironment = false; bool m_isStrictKillSwitchEnabled = false; - inline static QString m_proxyUrl; + QString m_proxyUrl; }; #endif // GATEWAYCONTROLLER_H diff --git a/client/core/controllers/updateController.cpp b/client/core/controllers/updateController.cpp index 24009705e..28c87a7f7 100644 --- a/client/core/controllers/updateController.cpp +++ b/client/core/controllers/updateController.cpp @@ -37,6 +37,8 @@ UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepos { } +UpdateController::~UpdateController() = default; + QString UpdateController::getRawChangelogText() const { return m_changelogText; @@ -97,6 +99,7 @@ void UpdateController::fetchGatewayUrl() m_appSettingsRepository->isDevGatewayEnv(), 7000, m_appSettingsRepository->isStrictKillSwitchEnabled()); + m_activeGatewayController = gatewayController; QJsonObject apiPayload; apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); @@ -107,6 +110,7 @@ void UpdateController::fetchGatewayUrl() QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() { gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload) .then(this, [this](QPair result) { + m_activeGatewayController.clear(); auto [err, gatewayResponse] = result; if (err != ErrorCode::NoError) { logger.error() << errorString(err); diff --git a/client/core/controllers/updateController.h b/client/core/controllers/updateController.h index 97ad361df..d04a2104e 100644 --- a/client/core/controllers/updateController.h +++ b/client/core/controllers/updateController.h @@ -7,11 +7,16 @@ #include "core/repositories/secureAppSettingsRepository.h" +#include + +class GatewayController; + class UpdateController : public QObject { Q_OBJECT public: explicit UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr); + ~UpdateController() override; QString getRawChangelogText() const; QString getReleaseDate() const; @@ -38,6 +43,8 @@ private: SecureAppSettingsRepository* m_appSettingsRepository; + QSharedPointer m_activeGatewayController; + QString m_baseUrl; QString m_changelogText; QString m_version; diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index 2223f0031..8e2348726 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -170,6 +170,13 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl qDebug() << replyError; qDebug() << httpStatusCode; + if (httpStatusCode == httpStatusCodeNotFound) { + const QJsonDocument probe = QJsonDocument::fromJson(responseBody); + if (!probe.isObject()) { + return amnezia::ErrorCode::ApiNotFoundError; + } + } + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody); if (jsonDoc.isObject()) { QJsonObject jsonObj = jsonDoc.object(); diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 123aa8d8a..c198b44be 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -255,6 +255,10 @@ bool SubscriptionUiController::restoreServiceFromAppStore() bool SubscriptionUiController::importFreeFromGateway() { + if (!isCaptchaAwaitingUser()) { + m_subscriptionController->clearGatewayCaptchaSticky(); + } + QString userCountryCode = m_apiServicesModel->getCountryCode(); QString serviceType = m_apiServicesModel->getSelectedServiceType(); QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol(); @@ -307,6 +311,7 @@ void SubscriptionUiController::onCaptchaSolved(const QString &captchaId, const Q protocolData.xrayUuid = m_captchaState.xrayUuid; ServerConfig serverConfig; + SubscriptionController::CaptchaInfo retryCaptcha; ErrorCode errorCode = m_subscriptionController->resolveImportServiceCaptcha( m_captchaState.userCountryCode, m_captchaState.serviceType, @@ -314,16 +319,26 @@ void SubscriptionUiController::onCaptchaSolved(const QString &captchaId, const Q protocolData, captchaId, solution, - serverConfig); - - m_captchaState.isPending = false; + serverConfig, + &retryCaptcha); if (errorCode == ErrorCode::NoError) { + m_captchaState.isPending = false; + emit captchaFlowDismissRequested(); m_serversController->addServer(serverConfig); emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); - } else { - emit errorOccurred(errorCode); + return; } + + if ((errorCode == ErrorCode::ApiCaptchaInvalidError || errorCode == ErrorCode::ApiCaptchaRefreshError) + && retryCaptcha.isRequired) { + emit captchaRequired(retryCaptcha.captchaId, retryCaptcha.captchaImageBase64, + retryCaptcha.hint.isEmpty() ? tr("Enter the digits from the image to continue") : retryCaptcha.hint); + return; + } + + m_captchaState.isPending = false; + emit errorOccurred(errorCode); } void SubscriptionUiController::onRefreshCaptchaRequested() diff --git a/client/ui/controllers/api/subscriptionUiController.h b/client/ui/controllers/api/subscriptionUiController.h index d4ed056d8..e60e6b9e1 100644 --- a/client/ui/controllers/api/subscriptionUiController.h +++ b/client/ui/controllers/api/subscriptionUiController.h @@ -84,6 +84,7 @@ signals: void vpnKeyExportReady(); void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint); + void captchaFlowDismissRequested(); private: struct CaptchaState { diff --git a/client/ui/qml/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml index 35cf981e2..d95389864 100644 --- a/client/ui/qml/Controls2/CaptchaDialogType.qml +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -40,6 +40,18 @@ Popup { solutionField.textField.focus = true } + onCaptchaIdChanged: { + if (opened) { + solutionField.textField.text = "" + } + } + + onCaptchaImageBase64Changed: { + if (opened) { + solutionField.textField.text = "" + } + } + onClosed: { FocusController.dropRootObject(root) } diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 2a7730482..0ee4cee39 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -214,7 +214,6 @@ Window { id: captchaDialog onCaptchaSolved: function(captchaId, solution) { - captchaDialog.close() SubscriptionUiController.onCaptchaSolved(captchaId, solution) } @@ -336,6 +335,10 @@ Window { captchaDialog.hint = hint captchaDialog.open() } + + function onCaptchaFlowDismissRequested() { + captchaDialog.close() + } } Connections { From b103d3966de7dc63b900e8a9526db8aafbeec584 Mon Sep 17 00:00:00 2001 From: dranik Date: Tue, 5 May 2026 16:50:35 +0300 Subject: [PATCH 13/13] ver 4.9.0.1 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b182af6d..352383e79 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.9.1.1) +set(AMNEZIAVPN_VERSION 4.9.0.1) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES