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 {