diff --git a/CMakeLists.txt b/CMakeLists.txt index 11400bf73..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.8.15.4) +set(AMNEZIAVPN_VERSION 4.9.0.1) set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE) set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2f138157d..1036fb95b 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -34,6 +34,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=1) +endif() + if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID)) set(PACKAGES ${PACKAGES} Widgets) endif() diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index ce34d4690..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,16 +210,25 @@ 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, const QString &serviceProtocol, const ProtocolData &protocolData, - ServerConfig &serverConfig) + ServerConfig &serverConfig, + CaptchaInfo &captchaInfo) { GatewayRequestData gatewayRequestData { QSysInfo::productType(), QString(APP_VERSION), @@ -234,7 +244,25 @@ 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(); + 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; + } + + m_gatewayCaptchaStickyBase.clear(); + if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -244,11 +272,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; } @@ -1090,3 +1118,79 @@ 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, + CaptchaInfo *retryCaptchaOut) { + 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; + 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; + 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) { + 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 2780ab1da..f2ec3c54b 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,8 +112,17 @@ 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, 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, @@ -116,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 23ced44f3..4ef56c66b 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -41,17 +41,38 @@ namespace constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); +#ifdef AMNEZIA_LOCAL_GATEWAY + 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 constexpr int proxyStorageRequestTimeoutMsecs = 3000; } 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) @@ -69,6 +90,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)); +#ifdef AMNEZIA_LOCAL_GATEWAY + 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) { @@ -151,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; } @@ -174,10 +207,19 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api reply->deleteLater(); +#ifdef AMNEZIA_LOCAL_GATEWAY + 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, httpStatusCode)) { auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) { encRequestData.request.setUrl(url); return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); @@ -194,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; } @@ -206,18 +248,21 @@ 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) { + writeEffectiveRequestBase(outEffectiveRequestBase); return errorCode; } if (!decryptionResult.isDecryptionSuccessful) { qCritical() << "error when decrypting the request body"; + writeEffectiveRequestBase(outEffectiveRequestBase); return ErrorCode::ApiConfigDecryptionError; } - responseBody = decryptionResult.decryptedBody; + writeEffectiveRequestBase(outEffectiveRequestBase); return ErrorCode::NoError; } @@ -247,8 +292,18 @@ QFuture> GatewayController::postAsync(const QString reply->deleteLater(); +#ifdef AMNEZIA_LOCAL_GATEWAY + 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, @@ -256,7 +311,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; } @@ -273,7 +328,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, httpStatusCode)) { auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString(""); auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString(""); @@ -432,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; @@ -446,6 +502,22 @@ 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") || err == QLatin1String("refresh_captcha")) { + 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; } @@ -511,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; }); @@ -533,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/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/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index eca4689fb..8e2348726 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -33,6 +33,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; @@ -140,15 +149,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,35 +170,75 @@ 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(); - 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 == 0 && httpStatusCode >= 400) { + 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("refresh_captcha"), Qt::CaseInsensitive)) { + return amnezia::ErrorCode::ApiCaptchaRefreshError; + } + 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/utils/errorCodes.h b/client/core/utils/errorCodes.h index 00e8c6b20..65a432eed 100644 --- a/client/core/utils/errorCodes.h +++ b/client/core/utils/errorCodes.h @@ -97,6 +97,10 @@ namespace amnezia ApiSubscriptionNotActiveError = 1114, ApiNoPurchasedSubscriptionsError = 1115, ApiTrialAlreadyUsedError = 1116, + ApiCaptchaRequiredError = 1117, + ApiCaptchaInvalidError = 1118, + ApiCaptchaRefreshError = 1119, + ApiRateLimitError = 1120, // QFile errors OpenError = 1200, diff --git a/client/core/utils/errorStrings.cpp b/client/core/utils/errorStrings.cpp index 591716ec9..9d74cb9d5 100644 --- a/client/core/utils/errorStrings.cpp +++ b/client/core/utils/errorStrings.cpp @@ -83,6 +83,10 @@ 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::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 case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index 75d96553e..c198b44be 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()) { @@ -250,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(); @@ -260,21 +269,110 @@ 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("Enter the digits from the image 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; + SubscriptionController::CaptchaInfo retryCaptcha; + ErrorCode errorCode = m_subscriptionController->resolveImportServiceCaptcha( + m_captchaState.userCountryCode, + m_captchaState.serviceType, + m_captchaState.serviceProtocol, + protocolData, + captchaId, + solution, + 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())); + 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() +{ + 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("Enter the digits from the image 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 d64a2eb2d..e60e6b9e1 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,20 @@ signals: void apiConfigRemoved(const QString &message); void vpnKeyExportReady(); + void captchaRequired(const QString &captchaId, const QString &captchaImageBase64, const QString &hint); + void captchaFlowDismissRequested(); + +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/Controls2/CaptchaDialogType.qml b/client/ui/qml/Controls2/CaptchaDialogType.qml new file mode 100644 index 000000000..d95389864 --- /dev/null +++ b/client/ui/qml/Controls2/CaptchaDialogType.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import Qt5Compat.GraphicalEffects + +import Style 1.0 + +import "." +import "TextTypes" +import "../Config" + +Popup { + id: root + + property string captchaId + property string captchaImageBase64 + property string hint: qsTr("Enter the digits from the image to continue") + + signal captchaSolved(string captchaId, string solution) + signal refreshCaptchaRequested() + + 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() + solutionField.textField.text = "" + solutionField.textField.focus = true + } + + onCaptchaIdChanged: { + if (opened) { + solutionField.textField.text = "" + } + } + + onCaptchaImageBase64Changed: { + if (opened) { + solutionField.textField.text = "" + } + } + + onClosed: { + FocusController.dropRootObject(root) + } + + background: Rectangle { + anchors.fill: parent + color: AmneziaStyle.color.slateGray + radius: 22 + } + + Timer { + id: timer + interval: 200 + onTriggered: { + FocusController.pushRootObject(root) + FocusController.setFocusItem(solutionField.textField) + } + repeat: false + running: true + } + + contentItem: Item { + implicitWidth: contentLayout.implicitWidth + implicitHeight: contentLayout.implicitHeight + + anchors.fill: parent + + ColumnLayout { + id: contentLayout + + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + anchors.bottomMargin: 20 + + spacing: 16 + + Text { + id: titleText + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + + 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 + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 200 + + Rectangle { + id: imagePanel + + 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 + } + } + } + + 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() + } + } + } + } + + 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() + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 8 + } + + BasicButtonType { + id: continueButton + + Layout.fillWidth: true + implicitHeight: 52 + + text: qsTr("Continue") + defaultColor: AmneziaStyle.color.paleGray + hoveredColor: AmneziaStyle.color.lightGray + pressedColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.midnightBlack + + clickedFunc: function() { + submitIfNonEmpty() + } + } + + BasicButtonType { + id: closeButton + + Layout.fillWidth: true + implicitHeight: 52 + + 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 + + clickedFunc: function() { + root.close() + } + } + } + } + + function submitIfNonEmpty() { + const t = solutionField.textField.text.trim() + if (t !== "") { + root.captchaSolved(root.captchaId, t) + } + } +} diff --git a/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml index 90135962e..af974915d 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 (SubscriptionUiController.isCaptchaAwaitingUser()) { + return + } var endpoint = ApiServicesModel.getStoreEndpoint() Qt.openUrlExternally(endpoint) PageController.closePage() diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index bf3ab0518..0ee4cee39 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -205,6 +205,24 @@ Window { } } + Item { + objectName: "captchaDialogItem" + + anchors.fill: parent + + CaptchaDialogType { + id: captchaDialog + + onCaptchaSolved: function(captchaId, solution) { + SubscriptionUiController.onCaptchaSolved(captchaId, solution) + } + + onRefreshCaptchaRequested: function() { + SubscriptionUiController.onRefreshCaptchaRequested() + } + } + } + Item { objectName: "privateKeyPassphraseDrawerItem" @@ -310,6 +328,17 @@ Window { function onSubscriptionExpiredOnServer() { subscriptionExpiredDrawer.openTriggered() } + + function onCaptchaRequired(captchaId, captchaImageBase64, hint) { + captchaDialog.captchaId = captchaId + captchaDialog.captchaImageBase64 = captchaImageBase64 + captchaDialog.hint = hint + captchaDialog.open() + } + + function onCaptchaFlowDismissRequested() { + captchaDialog.close() + } } Connections { diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index f2a462630..6a004038a 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -24,6 +24,7 @@ Controls2/BackButtonType.qml Controls2/BasicButtonType.qml Controls2/BusyIndicatorType.qml + Controls2/CaptchaDialogType.qml Controls2/CardType.qml Controls2/CardWithIconsType.qml Controls2/CheckBoxType.qml 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/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..35905aa19 --- /dev/null +++ b/tools/local_gateway/main.go @@ -0,0 +1,194 @@ +// 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) + 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) { + 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": "Enter the digits from the image 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)) +}