fixed open Qr QML & add check error code & add test

This commit is contained in:
dranik
2026-05-07 19:15:28 +03:00
parent 2cb7b30d8a
commit 5583c0a2a9
20 changed files with 884 additions and 76 deletions

View File

@@ -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.2)
set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE)
set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES

View File

@@ -204,6 +204,10 @@ list(APPEND SOURCES ${CMAKE_CURRENT_LIST_DIR}/main.cpp)
target_link_libraries(${PROJECT} PRIVATE ${LIBS})
target_compile_definitions(${PROJECT} PRIVATE "MZ_$<UPPER_CASE:${MZ_PLATFORM_NAME}>")
if(AMNEZIA_LOCAL_GATEWAY)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LOCAL_GATEWAY)
endif()
target_sources(${PROJECT} PRIVATE ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC} ${I18NQRC})
# Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this).

View File

@@ -16,11 +16,16 @@ namespace
{
constexpr auto kGenerateQrEndpoint = "%1api/v1/generate_qr";
constexpr auto kScanQrEndpoint = "%1api/v1/scan_qr";
constexpr qsizetype kPairingMaxQrUuidChars = 128;
constexpr qsizetype kPairingMaxVpnConfigChars = 256 * 1024;
constexpr qsizetype kPairingMaxApiKeyChars = 8192;
bool isLocalGatewayHost(const QString &gatewayUrl)
{
return gatewayUrl.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive)
|| gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive);
|| gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive)
|| gatewayUrl.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)
|| gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive);
}
ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload)
@@ -118,6 +123,20 @@ ErrorCode PairingController::parseScanQrResponseBody(const QByteArray &responseB
return interpretScanQrJson(obj);
}
ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey)
{
if (qrUuid.size() > kPairingMaxQrUuidChars) {
return ErrorCode::ApiConfigEmptyError;
}
if (vpnConfig.size() > kPairingMaxVpnConfigChars) {
return ErrorCode::ApiPairingPayloadTooLargeError;
}
if (apiKey.size() > kPairingMaxApiKeyChars) {
return ErrorCode::ApiPairingPayloadTooLargeError;
}
return ErrorCode::NoError;
}
PairingController::PairingController(SecureAppSettingsRepository *appSettingsRepository)
: m_appSettingsRepository(appSettingsRepository)
{
@@ -187,6 +206,11 @@ ErrorCode PairingController::completePairing(const QString &qrUuid, const QStrin
return ErrorCode::ApiConfigEmptyError;
}
const ErrorCode fieldErr = validatePairingScanFields(qrUuid, vpnConfig, apiKey);
if (fieldErr != ErrorCode::NoError) {
return fieldErr;
}
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(), m_appSettingsRepository->isDevGatewayEnv(),
apiDefs::requestTimeoutMsecs, m_appSettingsRepository->isStrictKillSwitchEnabled());

View File

@@ -34,6 +34,9 @@ public:
static amnezia::ErrorCode parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload);
static amnezia::ErrorCode parseScanQrResponseBody(const QByteArray &responseBody);
/** Length bounds before `scan_qr` (avoids huge JSON / abuse). */
static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey);
amnezia::ErrorCode startPairing(const QString &qrUuid, QrPairingConfigPayload &outPayload);
amnezia::ErrorCode completePairing(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, const QString &apiKey);

View File

@@ -312,6 +312,83 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
return ErrorCode::NoError;
}
ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols,
ServerConfig &serverConfig, int *duplicateServerIndex)
{
if (vpnConfigKey.isEmpty()) {
return ErrorCode::ApiConfigEmptyError;
}
QString normalizedKey = vpnConfigKey;
normalizedKey.replace(QStringLiteral("vpn://"), QString());
for (int i = 0; i < m_serversRepository->serversCount(); ++i) {
ServerConfig existingServerConfig = m_serversRepository->server(i);
QString existingVpnKey;
if (existingServerConfig.isApiV1()) {
const ApiV1ServerConfig *apiV1 = existingServerConfig.as<ApiV1ServerConfig>();
existingVpnKey = apiV1 ? apiV1->vpnKey() : QString();
} else if (existingServerConfig.isApiV2()) {
const ApiV2ServerConfig *apiV2 = existingServerConfig.as<ApiV2ServerConfig>();
existingVpnKey = apiV2 ? apiV2->vpnKey() : QString();
}
existingVpnKey.replace(QStringLiteral("vpn://"), QString());
if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) {
if (duplicateServerIndex) {
*duplicateServerIndex = i;
}
return ErrorCode::ApiConfigAlreadyAdded;
}
}
QByteArray configString =
QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configString);
if (!configUncompressed.isEmpty()) {
configString = configUncompressed;
}
if (configString.isEmpty()) {
return ErrorCode::ApiConfigEmptyError;
}
QJsonObject serverJson = QJsonDocument::fromJson(configString).object();
if (serverJson.isEmpty()) {
return ErrorCode::ApiConfigEmptyError;
}
if (serverJson.value(configKey::configVersion).toInt() != apiDefs::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
QJsonObject apiConfig = serverJson.value(apiDefs::key::apiConfig).toObject();
if (!serviceInfo.isEmpty()) {
apiConfig.insert(apiDefs::key::serviceInfo, serviceInfo);
}
if (!supportedProtocols.isEmpty()) {
apiConfig.insert(apiDefs::key::supportedProtocols, supportedProtocols);
}
serverJson[apiDefs::key::apiConfig] = apiConfig;
ServerConfig serverConfigModel = ServerConfig::fromJson(serverJson);
if (!serverConfigModel.isApiV2()) {
return ErrorCode::InternalError;
}
ApiV2ServerConfig *apiV2 = serverConfigModel.as<ApiV2ServerConfig>();
if (apiV2 && apiV2->apiConfig.vpnKey.isEmpty()) {
QString fullKey = vpnConfigKey.trimmed();
if (!fullKey.startsWith(QStringLiteral("vpn://"))) {
fullKey = QStringLiteral("vpn://") + fullKey;
}
apiV2->apiConfig.vpnKey = fullKey;
}
m_serversRepository->addServer(serverConfigModel);
serverConfig = serverConfigModel;
return ErrorCode::NoError;
}
ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,

View File

@@ -1,6 +1,7 @@
#ifndef SUBSCRIPTIONCONTROLLER_H
#define SUBSCRIPTIONCONTROLLER_H
#include <QJsonArray>
#include <QJsonObject>
#include <QByteArray>
#include <QFuture>
@@ -57,6 +58,11 @@ public:
const QString &serviceProtocol, const QString &email,
ServerConfig &serverConfig);
/** Decode premium API (vpn://) bundle from QR pairing TV response, merge gateway fields, add server. */
ErrorCode importServerFromQrPairingResponse(const QString &vpnConfigKey, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, ServerConfig &serverConfig,
int *duplicateServerIndex = nullptr);
ErrorCode importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,

View File

@@ -86,6 +86,18 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
}
#endif
#ifdef AMNEZIA_LOCAL_GATEWAY
{
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
const QString host = gatewayUrl.host().toLower();
if (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")) {
encRequestData.isPlaintextLocalGateway = true;
encRequestData.requestBody = QJsonDocument(apiPayload).toJson();
return encRequestData;
}
}
#endif
QSimpleCrypto::QBlockCipher blockCipher;
encRequestData.key = blockCipher.generatePrivateSalt(32);
encRequestData.iv = blockCipher.generatePrivateSalt(32);
@@ -176,6 +188,16 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
reply->deleteLater();
if (encRequestData.isPlaintextLocalGateway) {
const auto errorCode =
apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, encryptedResponseBody);
if (errorCode) {
return errorCode;
}
responseBody = encryptedResponseBody;
return ErrorCode::NoError;
}
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
@@ -223,7 +245,8 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
return ErrorCode::NoError;
}
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload)
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject &apiPayload,
QNetworkReply **activeReplyOut)
{
auto promise = QSharedPointer<QPromise<QPair<ErrorCode, QByteArray>>>::create();
promise->start();
@@ -236,6 +259,9 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
}
QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
if (activeReplyOut) {
*activeReplyOut = reply;
}
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
@@ -249,6 +275,18 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
reply->deleteLater();
if (encRequestData.isPlaintextLocalGateway) {
const auto errorCode = apiUtils::checkNetworkReplyErrors(*sslErrors, replyErrorString, replyError, httpStatusCode,
encryptedResponseBody);
if (errorCode) {
promise->addResult(qMakePair(errorCode, QByteArray()));
} else {
promise->addResult(qMakePair(ErrorCode::NoError, encryptedResponseBody));
}
promise->finish();
return;
}
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);

View File

@@ -25,7 +25,9 @@ public:
const bool isStrictKillSwitchEnabled, QObject *parent = nullptr);
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
/** If \a activeReplyOut is non-null, the underlying QNetworkReply is written for abort/cancel (not owned by caller). */
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject &apiPayload,
QNetworkReply **activeReplyOut = nullptr);
private:
struct EncryptedRequestData
@@ -36,6 +38,7 @@ private:
QByteArray iv;
QByteArray salt;
amnezia::ErrorCode errorCode;
bool isPlaintextLocalGateway = false;
};
struct DecryptionResult

View File

@@ -16,7 +16,12 @@
using namespace amnezia;
namespace {
#ifdef AMNEZIA_LOCAL_GATEWAY
// Prefer 127.0.0.1 with local mock (tools/local_gateway listens on 0.0.0.0:8080); avoids LAN/IPv6 ambiguity in dev.
constexpr char gatewayEndpoint[] = "http://127.0.0.1:8080/";
#else
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
#endif
}
SecureAppSettingsRepository::SecureAppSettingsRepository(SecureQSettings* settings, QObject *parent)
@@ -246,6 +251,15 @@ void SecureAppSettingsRepository::setAppsSplitTunnelingEnabled(bool enabled)
QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) const
{
if (isTestPurchase) {
// App Store / sandbox subscriptions set isTestPurchase; the stock rule swaps the base URL to
// DEV_AGW_ENDPOINT. For tools/local_gateway (127.0.0.1 / localhost) that sends encrypted
// traffic to the wrong host, decryption fails, shouldBypassProxy pulls S3 — crash or "Send failed".
const QString &base = m_gatewayEndpoint;
if (base.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive)
|| base.contains(QStringLiteral("localhost"), Qt::CaseInsensitive)
|| base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) {
return m_gatewayEndpoint;
}
return QString(DEV_AGW_ENDPOINT);
}
return m_gatewayEndpoint;

View File

@@ -103,6 +103,7 @@ namespace amnezia
ApiPairingConflictError = 1118,
ApiPairingRateLimitedError = 1119,
ApiPairingServiceUnavailableError = 1120,
ApiPairingPayloadTooLargeError = 1121,
// QFile errors
OpenError = 1200,

View File

@@ -87,6 +87,7 @@ QString errorString(ErrorCode code) {
case (ErrorCode::ApiPairingConflictError): errorMessage = QObject::tr("This QR code has already been used"); break;
case (ErrorCode::ApiPairingRateLimitedError): errorMessage = QObject::tr("Too many requests. Please try again later"); break;
case (ErrorCode::ApiPairingServiceUnavailableError): errorMessage = QObject::tr("Service temporarily unavailable. Please try again later"); break;
case (ErrorCode::ApiPairingPayloadTooLargeError): errorMessage = QObject::tr("QR pairing data is too large to send"); break;
// QFile errors
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;

View File

@@ -9,6 +9,7 @@
#include "android_controller.h"
#include "android_utils.h"
#include "ui/controllers/importUiController.h"
#include "ui/controllers/api/pairingUiController.h"
namespace
{
@@ -538,7 +539,11 @@ bool AndroidController::decodeQrCode(JNIEnv *env, jobject thiz, jstring data)
{
Q_UNUSED(thiz);
return ImportUiController::decodeQrCode(AndroidUtils::convertJString(env, data));
const QString code = AndroidUtils::convertJString(env, data);
if (PairingUiController::tryConsumeAndroidQrScan(code)) {
return true;
}
return ImportUiController::decodeQrCode(code);
}
// static
void AndroidController::onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp)

View File

@@ -140,6 +140,15 @@ target_link_libraries(test_self_hosted_server_setup PRIVATE
test_common
)
add_executable(test_pairing_parsers
testPairingParsers.cpp
)
target_link_libraries(test_pairing_parsers PRIVATE
Qt6::Test
test_common
)
enable_testing()
add_test(NAME ImportExportTest COMMAND test_import_export)
add_test(NAME MultipleImportsTest COMMAND test_multiple_imports)
@@ -153,3 +162,4 @@ add_test(NAME ComplexOperationsTest COMMAND test_complex_operations)
add_test(NAME SettingsSignalsTest COMMAND test_settings_signals)
add_test(NAME UiServersModelAndControllerTest COMMAND test_ui_servers_model_and_controller)
add_test(NAME SelfHostedServerSetupTest COMMAND test_self_hosted_server_setup)
add_test(NAME PairingParsersTest COMMAND test_pairing_parsers)

View File

@@ -0,0 +1,126 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTest>
#include "core/controllers/api/pairingController.h"
#include "ui/controllers/api/pairingUiController.h"
#include "core/utils/constants/apiKeys.h"
using namespace amnezia;
class TestPairingParsers : public QObject
{
Q_OBJECT
private slots:
void generateQr_success_extractsConfigAndMeta()
{
PairingController::QrPairingConfigPayload out;
QJsonObject o;
o[apiDefs::key::config] = QStringLiteral("vpn://dummy");
o[apiDefs::key::serviceInfo] = QJsonObject { { QStringLiteral("is_ad_visible"), false } };
o[apiDefs::key::supportedProtocols] = QJsonArray { QStringLiteral("awg") };
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::NoError);
QCOMPARE(out.config, QStringLiteral("vpn://dummy"));
QCOMPARE(out.supportedProtocols.size(), 1);
}
void generateQr_http408()
{
PairingController::QrPairingConfigPayload out;
QJsonObject o;
o[QStringLiteral("http_status")] = 408;
o[QStringLiteral("message")] = QStringLiteral("Request Timeout");
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiConfigTimeoutError);
QVERIFY(out.config.isEmpty());
}
void generateQr_http429()
{
PairingController::QrPairingConfigPayload out;
QJsonObject o;
o[QStringLiteral("http_status")] = 429;
o[QStringLiteral("message")] = QStringLiteral("Too Many Requests");
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseGenerateQrResponseBody(body, out), ErrorCode::ApiPairingRateLimitedError);
}
void scanQr_messageOk()
{
QJsonObject o;
o[QStringLiteral("message")] = QStringLiteral("OK");
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::NoError);
}
void scanQr_http403()
{
QJsonObject o;
o[QStringLiteral("http_status")] = 403;
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingForbiddenError);
}
void scanQr_http409()
{
QJsonObject o;
o[QStringLiteral("http_status")] = 409;
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiPairingConflictError);
}
void scanQr_notFoundMessage()
{
QJsonObject o;
o[QStringLiteral("message")] = QStringLiteral("Session not found");
const QByteArray body = QJsonDocument(o).toJson();
QCOMPARE(PairingController::parseScanQrResponseBody(body), ErrorCode::ApiNotFoundError);
}
void validateScanFields_oversizedVpnKey()
{
QString vpnKey;
vpnKey.fill(QLatin1Char('x'), 256 * 1024 + 1);
QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), vpnKey, QStringLiteral("k")),
ErrorCode::ApiPairingPayloadTooLargeError);
}
void validateScanFields_uuidTooLong()
{
QString uuid(200, QLatin1Char('a'));
QCOMPARE(PairingController::validatePairingScanFields(uuid, QStringLiteral("vpn://a"), QStringLiteral("k")),
ErrorCode::ApiConfigEmptyError);
}
void pairingUi_applyScanned_extractsUuid_emitsSignal()
{
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan);
const QString u = QStringLiteral("123e4567-e89b-12d3-a456-426614174000");
QVERIFY(ctl.applyScannedTextAsPairingUuid(QStringLiteral("prefix ") + u + QStringLiteral(" suffix")));
QCOMPARE(spy.count(), 1);
QCOMPARE(spy.first().first().toString(), u);
}
void pairingUi_applyScanned_rejectsVpnKey()
{
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
QSignalSpy spy(&ctl, &PairingUiController::pairingUuidFromScan);
QVERIFY(!ctl.applyScannedTextAsPairingUuid(QStringLiteral("vpn://AAAA")));
QCOMPARE(spy.count(), 0);
}
};
QTEST_MAIN(TestPairingParsers)
#include "testPairingParsers.moc"

View File

@@ -1,7 +1,13 @@
#include "pairingUiController.h"
#include <QRegularExpression>
#include <QTimer>
#include <QUuid>
#if defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#endif
#include "core/controllers/gatewayController.h"
#include "core/models/serverConfig.h"
#include "core/models/api/apiV2ServerConfig.h"
@@ -14,8 +20,33 @@ namespace
{
constexpr auto kGenerateQrPath = "%1api/v1/generate_qr";
constexpr auto kScanQrPath = "%1api/v1/scan_qr";
constexpr int kPairingRetryMaxAttempts = 3;
bool isPairingRetriableError(ErrorCode code)
{
switch (code) {
case ErrorCode::ApiPairingRateLimitedError:
case ErrorCode::ApiPairingServiceUnavailableError:
case ErrorCode::ApiConfigDownloadError:
return true;
default:
return false;
}
}
int pairingRetryDelayMs(int zeroBasedAttempt)
{
constexpr int baseMs = 500;
return baseMs * (1 << zeroBasedAttempt);
}
} // namespace
#if defined(Q_OS_ANDROID)
namespace {
PairingUiController *g_pairingUiForAndroidQr = nullptr;
}
#endif
PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController,
SubscriptionController *subscriptionController,
SecureAppSettingsRepository *appSettingsRepository, QObject *parent)
@@ -25,8 +56,63 @@ PairingUiController::PairingUiController(PairingController *pairingController, S
m_subscriptionController(subscriptionController),
m_appSettingsRepository(appSettingsRepository)
{
#if defined(Q_OS_ANDROID)
g_pairingUiForAndroidQr = this;
#endif
}
PairingUiController::~PairingUiController()
{
#if defined(Q_OS_ANDROID)
if (g_pairingUiForAndroidQr == this) {
g_pairingUiForAndroidQr = nullptr;
}
#endif
}
void PairingUiController::setTvPairingUiPhase(int phase)
{
if (m_tvPairingUiPhase == phase) {
return;
}
m_tvPairingUiPhase = phase;
emit tvPairingUiPhaseChanged();
}
void PairingUiController::openPairingQrScanner()
{
#if defined(Q_OS_ANDROID)
AndroidController::instance()->startQrReaderActivity();
#endif
}
bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
{
const QString t = raw.trimmed();
if (t.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) {
return false;
}
static const QRegularExpression re(QStringLiteral(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"));
const QRegularExpressionMatch m = re.match(t);
if (!m.hasMatch()) {
return false;
}
const QString uuid = m.captured(0);
emit pairingUuidFromScan(uuid);
return true;
}
#if defined(Q_OS_ANDROID)
bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
{
if (!g_pairingUiForAndroidQr) {
return false;
}
return g_pairingUiForAndroidQr->applyScannedTextAsPairingUuid(code);
}
#endif
QVariantList PairingUiController::tvQrCodes() const
{
QVariantList list;
@@ -93,6 +179,18 @@ void PairingUiController::resetTvQrDisplay()
emit tvSessionUuidChanged();
}
QString PairingUiController::tvFailureMessage(ErrorCode code) const
{
switch (code) {
case ErrorCode::ApiConfigTimeoutError:
return tr("QR session expired. Tap Start to show a new QR code.");
case ErrorCode::ApiConfigAlreadyAdded:
return tr("This configuration is already on the device.");
default:
return tr("Pairing failed");
}
}
void PairingUiController::startTvQrSession()
{
if (!m_pairingController || !m_appSettingsRepository) {
@@ -108,6 +206,9 @@ void PairingUiController::startTvQrSession()
m_tvWatcher.clear();
}
++m_tvSessionGeneration;
const quint64 generation = m_tvSessionGeneration;
m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QByteArray qrPayload = m_tvSessionUuid.toUtf8();
m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload);
@@ -118,6 +219,19 @@ void PairingUiController::startTvQrSession()
emit tvStatusMessageChanged();
setTvBusy(true);
setTvPairingUiPhase(1);
dispatchTvGenerateQrAttempt(generation, 0);
}
void PairingUiController::dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt)
{
if (!m_pairingController || !m_appSettingsRepository) {
return;
}
if (generation != m_tvSessionGeneration) {
return;
}
const bool isTestPurchase = false;
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
@@ -126,12 +240,15 @@ void PairingUiController::startTvQrSession()
m_appSettingsRepository->isStrictKillSwitchEnabled());
const QJsonObject payload = m_pairingController->buildGenerateQrPayload(m_tvSessionUuid);
const QFuture<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload);
QNetworkReply *replyRaw = nullptr;
const QFuture<QPair<ErrorCode, QByteArray>> future =
gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw);
m_tvNetworkReply = replyRaw;
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
m_tvWatcher = watcher;
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished, this,
[this, gatewayController, watcher]() {
[this, gatewayController, watcher, generation, retryAttempt]() {
Q_UNUSED(gatewayController);
const auto result = watcher->result();
watcher->deleteLater();
@@ -139,33 +256,67 @@ void PairingUiController::startTvQrSession()
m_tvWatcher.clear();
}
setTvBusy(false);
if (result.first != ErrorCode::NoError) {
m_tvStatusMessage = tr("Pairing failed");
emit tvStatusMessageChanged();
emit errorOccurred(result.first);
if (generation != m_tvSessionGeneration) {
return;
}
m_tvNetworkReply.clear();
PairingController::QrPairingConfigPayload out;
const ErrorCode parseErr = PairingController::parseGenerateQrResponseBody(result.second, out);
if (parseErr != ErrorCode::NoError) {
m_tvStatusMessage = tr("Pairing failed");
ErrorCode logicalErr = result.first;
if (logicalErr == ErrorCode::NoError) {
logicalErr = PairingController::parseGenerateQrResponseBody(result.second, out);
}
if (logicalErr == ErrorCode::NoError) {
ServerConfig importedConfig;
const ErrorCode impErr = m_subscriptionController->importServerFromQrPairingResponse(
out.config, out.serviceInfo, out.supportedProtocols, importedConfig);
Q_UNUSED(importedConfig);
setTvBusy(false);
if (impErr != ErrorCode::NoError) {
setTvPairingUiPhase(2);
m_tvStatusMessage = tvFailureMessage(impErr);
emit tvStatusMessageChanged();
emit errorOccurred(impErr);
resetTvQrDisplay();
return;
}
resetTvQrDisplay();
m_tvStatusMessage = tr("Configuration received");
emit tvStatusMessageChanged();
emit errorOccurred(parseErr);
emit tvPairingConfigReceived();
setTvPairingUiPhase(0);
return;
}
m_tvStatusMessage = tr("Configuration received");
if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) {
const int delayMs = pairingRetryDelayMs(retryAttempt);
QTimer::singleShot(delayMs, this, [this, generation, retryAttempt]() {
if (generation != m_tvSessionGeneration) {
return;
}
dispatchTvGenerateQrAttempt(generation, retryAttempt + 1);
});
return;
}
setTvBusy(false);
setTvPairingUiPhase(logicalErr == ErrorCode::ApiConfigTimeoutError ? 3 : 2);
m_tvStatusMessage = tvFailureMessage(logicalErr);
emit tvStatusMessageChanged();
emit tvPairingConfigReceived();
emit errorOccurred(logicalErr);
});
watcher->setFuture(future);
}
void PairingUiController::cancelTvQrSession()
{
++m_tvSessionGeneration;
if (m_tvNetworkReply) {
m_tvNetworkReply->abort();
}
m_tvNetworkReply.clear();
if (m_tvWatcher) {
m_tvWatcher->disconnect();
m_tvWatcher->deleteLater();
@@ -175,6 +326,26 @@ void PairingUiController::cancelTvQrSession()
m_tvStatusMessage.clear();
emit tvStatusMessageChanged();
resetTvQrDisplay();
setTvPairingUiPhase(0);
}
void PairingUiController::cancelAllPairingActivity()
{
++m_phoneSessionGeneration;
if (m_phoneNetworkReply) {
m_phoneNetworkReply->abort();
}
m_phoneNetworkReply.clear();
if (m_phoneWatcher) {
m_phoneWatcher->disconnect();
m_phoneWatcher->deleteLater();
m_phoneWatcher.clear();
}
setPhoneBusy(false);
m_phoneStatusMessage.clear();
emit phoneStatusMessageChanged();
cancelTvQrSession();
}
void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIndex)
@@ -224,29 +395,50 @@ void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIn
return;
}
const ErrorCode fieldErr = PairingController::validatePairingScanFields(trimmedUuid, vpnKey, apiKey);
if (fieldErr != ErrorCode::NoError) {
emit errorOccurred(fieldErr);
return;
}
++m_phoneSessionGeneration;
const quint64 phoneGeneration = m_phoneSessionGeneration;
m_phoneStatusMessage = tr("Sending…");
emit phoneStatusMessageChanged();
setPhoneBusy(true);
runPhonePairingRequest(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey);
dispatchPhoneScanQrAttempt(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
phoneGeneration, 0);
}
void PairingUiController::runPhonePairingRequest(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey,
const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols,
const QString &apiKey)
void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey,
const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols,
const QString &apiKey, quint64 generation, int retryAttempt)
{
if (!m_pairingController || !m_appSettingsRepository) {
return;
}
if (generation != m_phoneSessionGeneration) {
return;
}
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase),
apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled());
const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey);
const QFuture<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload);
QNetworkReply *replyRaw = nullptr;
const QFuture<QPair<ErrorCode, QByteArray>> future =
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw);
m_phoneNetworkReply = replyRaw;
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
m_phoneWatcher = watcher;
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished, this,
[this, gatewayController, watcher]() {
[this, gatewayController, watcher, generation, retryAttempt, qrUuid, isTestPurchase, vpnKey, serviceInfo,
supportedProtocols, apiKey]() {
Q_UNUSED(gatewayController);
const auto result = watcher->result();
watcher->deleteLater();
@@ -254,26 +446,42 @@ void PairingUiController::runPhonePairingRequest(const QString &qrUuid, const bo
m_phoneWatcher.clear();
}
if (generation != m_phoneSessionGeneration) {
return;
}
m_phoneNetworkReply.clear();
ErrorCode logicalErr = result.first;
if (logicalErr == ErrorCode::NoError) {
logicalErr = PairingController::parseScanQrResponseBody(result.second);
}
if (logicalErr == ErrorCode::NoError) {
setPhoneBusy(false);
m_phoneStatusMessage = tr("Sent successfully");
emit phoneStatusMessageChanged();
emit phonePairingSucceeded();
return;
}
if (isPairingRetriableError(logicalErr) && retryAttempt + 1 < kPairingRetryMaxAttempts) {
const int delayMs = pairingRetryDelayMs(retryAttempt);
QTimer::singleShot(delayMs, this, [this, qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols,
apiKey, generation, retryAttempt]() {
if (generation != m_phoneSessionGeneration) {
return;
}
dispatchPhoneScanQrAttempt(qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
generation, retryAttempt + 1);
});
return;
}
setPhoneBusy(false);
if (result.first != ErrorCode::NoError) {
m_phoneStatusMessage = tr("Send failed");
emit phoneStatusMessageChanged();
emit errorOccurred(result.first);
return;
}
const ErrorCode parseErr = PairingController::parseScanQrResponseBody(result.second);
if (parseErr != ErrorCode::NoError) {
m_phoneStatusMessage = tr("Send failed");
emit phoneStatusMessageChanged();
emit errorOccurred(parseErr);
return;
}
m_phoneStatusMessage = tr("Sent successfully");
m_phoneStatusMessage = tr("Send failed");
emit phoneStatusMessageChanged();
emit phonePairingSucceeded();
emit errorOccurred(logicalErr);
});
watcher->setFuture(future);
}

View File

@@ -2,6 +2,7 @@
#define PAIRINGUICONTROLLER_H
#include <QFutureWatcher>
#include <QNetworkReply>
#include <QObject>
#include <QVariantList>
#include <QPointer>
@@ -26,11 +27,14 @@ class PairingUiController : public QObject
Q_PROPERTY(bool phonePairingBusy READ phonePairingBusy NOTIFY phonePairingBusyChanged)
Q_PROPERTY(QString phoneStatusMessage READ phoneStatusMessage NOTIFY phoneStatusMessageChanged)
/** TV flow for QA: 0=idle, 1=waitingForPeer, 2=error, 3=sessionExpired */
Q_PROPERTY(int tvPairingUiPhase READ tvPairingUiPhase NOTIFY tvPairingUiPhaseChanged)
public:
PairingUiController(PairingController *pairingController, ServersController *serversController,
SubscriptionController *subscriptionController, SecureAppSettingsRepository *appSettingsRepository,
QObject *parent = nullptr);
~PairingUiController() override;
QVariantList tvQrCodes() const;
int tvQrCodesCount() const;
@@ -40,14 +44,27 @@ public:
bool phonePairingBusy() const;
QString phoneStatusMessage() const;
int tvPairingUiPhase() const { return m_tvPairingUiPhase; }
#if defined(Q_OS_ANDROID)
static bool tryConsumeAndroidQrScan(const QString &code);
#endif
public slots:
void startTvQrSession();
void cancelTvQrSession();
/** TV receive + phone send: call when leaving QR pairing (back / pop) so long-poll state does not stick. */
void cancelAllPairingActivity();
/** Sends the current premium/free API config from \a serverIndex to the gateway for the given \a qrUuid. */
void submitPhonePairing(const QString &qrUuid, int serverIndex);
/** Android: system camera activity. iOS: toggle camera from QML. */
void openPairingQrScanner();
/** If \a raw contains a session UUID (not vpn://), emits pairingUuidFromScan and returns true. */
bool applyScannedTextAsPairingUuid(const QString &raw);
signals:
void errorOccurred(amnezia::ErrorCode errorCode);
void tvQrCodesChanged();
@@ -60,12 +77,18 @@ signals:
void tvPairingConfigReceived();
void phonePairingSucceeded();
void pairingUuidFromScan(const QString &uuid);
void tvPairingUiPhaseChanged();
private:
void setTvBusy(bool busy);
void setPhoneBusy(bool busy);
void resetTvQrDisplay();
void runPhonePairingRequest(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, const QString &apiKey);
QString tvFailureMessage(amnezia::ErrorCode code) const;
void dispatchTvGenerateQrAttempt(quint64 generation, int retryAttempt);
void dispatchPhoneScanQrAttempt(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, const QString &apiKey, quint64 generation, int retryAttempt);
void setTvPairingUiPhase(int phase);
PairingController *m_pairingController {};
ServersController *m_serversController {};
@@ -77,10 +100,15 @@ private:
bool m_tvPairingBusy = false;
QString m_tvStatusMessage;
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_tvWatcher;
QPointer<QNetworkReply> m_tvNetworkReply;
quint64 m_tvSessionGeneration { 0 };
int m_tvPairingUiPhase { 0 };
bool m_phonePairingBusy = false;
QString m_phoneStatusMessage;
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_phoneWatcher;
QPointer<QNetworkReply> m_phoneNetworkReply;
quint64 m_phoneSessionGeneration { 0 };
};
#endif // PAIRINGUICONTROLLER_H

View File

@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QRCodeReader 1.0
import PageEnum 1.0
import Style 1.0
@@ -14,6 +15,18 @@ PageType {
id: root
property int qrImageIndex: 0
property bool pairingCameraOpen: false
Connections {
target: root
function onVisibleChanged() {
if (!root.visible) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PairingUiController.cancelAllPairingActivity()
}
}
}
FlickableType {
anchors.fill: parent
@@ -74,8 +87,9 @@ PageType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
defaultColor: "transparent"
text: qsTr("Cancel receive")
// Do not use defaultColor: transparent here: when enabled, BasicButtonType paints that
// as the idle background, so midnightBlack label sits on the page — invisible until hover.
enabled: PairingUiController.tvPairingBusy
clickedFunc: function() {
PairingUiController.cancelTvQrSession()
@@ -91,21 +105,29 @@ PageType {
wrapMode: Text.Wrap
}
Image {
id: qrImage
Layout.alignment: Qt.AlignHCenter
// SVG QR from qrCodeUtils has a tiny viewBox (~45px); without a sized container + sourceSize it stays small.
Item {
id: qrBox
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
implicitHeight: width
visible: PairingUiController.tvQrCodesCount > 0
width: Math.min(220, parent.width - 32)
height: width
fillMode: Image.PreserveAspectFit
source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : ""
MouseArea {
Image {
id: qrImage
anchors.fill: parent
enabled: PairingUiController.tvQrCodesCount > 1
onClicked: {
root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(2048, 2048)
source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : ""
MouseArea {
anchors.fill: parent
enabled: PairingUiController.tvQrCodesCount > 1
onClicked: {
root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount
}
}
}
}
@@ -130,12 +152,70 @@ PageType {
textField.placeholderText: qsTr("Paste UUID from TV QR")
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: Qt.platform.os === "android" || Qt.platform.os === "ios"
text: {
if (Qt.platform.os === "ios" && root.pairingCameraOpen) {
return qsTr("Hide camera")
}
return qsTr("Scan QR code")
}
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else {
root.pairingCameraOpen = !root.pairingCameraOpen
}
}
}
Item {
id: cameraSlot
Layout.fillWidth: true
Layout.preferredHeight: (root.pairingCameraOpen && Qt.platform.os === "ios") ? 220 : 0
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: Layout.preferredHeight > 0
clip: true
// QRCodeReader is a QObject (not Item): no anchors; preview rect via setCameraSize like PageSetupWizardQrReader.
QRCodeReader {
id: pairingQrReader
onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR"))
}
}
}
onVisibleChanged: {
if (!visible) {
pairingQrReader.stopReading()
return
}
if (Qt.platform.os === "ios") {
Qt.callLater(function() {
var p = cameraSlot.mapToItem(root, 0, 0)
pairingQrReader.setCameraSize(Qt.rect(p.x, p.y, cameraSlot.width, cameraSlot.height))
pairingQrReader.startReading()
})
}
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: PairingUiController.phonePairingBusy ? qsTr("Sending…") : qsTr("Send from current subscription")
enabled: !PairingUiController.tvPairingBusy && !PairingUiController.phonePairingBusy
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
PairingUiController.submitPhonePairing(uuidField.textField.text, ServersUiController.getProcessedServerIndex())
}
@@ -160,6 +240,11 @@ PageType {
root.qrImageIndex = 0
}
function onTvSessionUuidChanged() {
root.qrImageIndex = 0
uuidField.textField.text = PairingUiController.tvSessionUuid
}
function onTvPairingConfigReceived() {
PageController.showNotificationMessage(qsTr("Configuration received from gateway"))
}
@@ -167,5 +252,9 @@ PageType {
function onPhonePairingSucceeded() {
PageController.showNotificationMessage(qsTr("Configuration sent"))
}
function onPairingUuidFromScan(uuid) {
uuidField.textField.text = uuid
}
}
}

View File

@@ -22,26 +22,45 @@ go run .
После `git pull` обязательно **остановите старый процесс** на 8080 (`Ctrl+C` в терминале или `kill <PID>`), иначе будет крутиться бинарник без правок.
В логах должно появиться сообщение вида:
В логах при старте: `plaintext mock on tcp4 0.0.0.0:8080 — see ... README.md for paths`. Каждый запрос дополнительно пишется как `REQ <METHOD> <path>`.
`plaintext mock listening on 0.0.0.0:8080 GET / POST /v1/services POST /v1/config POST /api/v1/generate_qr POST /api/v1/scan_qr`
Проверка без клиента (mock должен быть запущен):
```bash
./verify.sh
# или
bash verify.sh http://127.0.0.1:8080
```
## Эндпоинты
| Метод | Путь | Назначение |
|--------|------|------------|
| `GET` | `/` | Короткий текст для проверки из браузера / телефона. |
| `POST` | `/v1/services` | Минимальный ответ со списком сервисов (в т.ч. `amnezia-free` / `awg`). |
| `POST` | `/v1/config` | Импорт конфига: лимит/CAPTCHA (`dchest/captcha`), проверка решения, мок-ответы. |
| `POST` | `/api/v1/generate_qr` | Регистрация pairing-сессии по `qr_uuid` + long-poll (**120s** в этом mock; **30s** на production gateway). |
| `POST` | `/api/v1/scan_qr` | Завершение pairing-сессии: передача `config` + `service_info` + `supported_protocols` по `qr_uuid`. |
| `GET` | `/VERSION` | Версия для цепочки обновлений (`UpdateController`: после `updater_endpoint`). Значение `0.0.1` — ниже клиента, «обновление не найдено». |
| `GET` | `/CHANGELOG` | Пустое тело, успех. |
| `GET` | `/RELEASE_DATE` | Пустое тело, успех. |
| `POST` | `/v1/account_info` | Экран APIподписки (`getAccountInfo`). |
| `POST` | `/v1/services` | Каталог сервисов (`ServicesCatalogController`). |
| `POST` | `/v1/config` | Amnezia Free: CAPTCHA/лимит; иначе короткий мок‑ответ (полноценный premium `vpn://` здесь не строится). |
| `POST` | `/v1/news` | Лента новостей (`NewsController`), пустой `news`. |
| `POST` | `/v1/renewal_link` | Ссылка продления (`renewal_url`). |
| `POST` | `/v1/updater_endpoint` | `{"url":"http://127.0.0.1:8080"}` → затем GET `/VERSION` на этом хосте. |
| `POST` | `/v1/revoke_config` | Успех, тело не разбирается при `NoError`. |
| `POST` | `/v1/revoke_native_config` | То же. |
| `POST` | `/api/v1/generate_qr` | Pairing: long-poll (**120s** mock). |
| `POST` | `/api/v1/scan_qr` | Pairing: завершение по `qr_uuid`. |
Других маршрутов нет (кроме `GET /`).
**Не реализовано** (нужен осмысленный `vpn://` / IAP): `POST /v1/trial`, `POST /v1/subscriptions`, `POST /v1/native_config`, `POST /v1/proxy_config` (Telegram). При необходимости — отдельная доработка или прод gateway.
**Обновление premium** (`updateServiceFromGateway``POST /v1/config` с `amnezia-premium`) требует валидного поля `config` с `vpn://…` в ответе; текущий mock для premium не подменяет полный конфиг — избегайте «Reload API config» на полностью локальном стенде или расширяйте mock.
## Связка с клиентом 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/`.
1. Соберите клиент с определением **`AMNEZIA_LOCAL_GATEWAY`** (см. `client/CMakeLists.txt`, `target_compile_definitions`) — тогда для **`127.0.0.1`** и **`localhost`** запросы к gateway уходят **plaintext JSON** без RSA/AES (см. `GatewayController`, `SecureAppSettingsRepository`).
2. В настройках приложения endpoint gateway: **`http://127.0.0.1:8080/`** (дефолт при `AMNEZIA_LOCAL_GATEWAY` в коде). Допустим и `http://localhost:8080/` — тоже plaintext.
Пошаговый план (включая следующие этапы вроде `/v1/account_info`): **`docs/local-gateway-mock.md`**.
После этого сценарии вроде **Amnezia Free → Continue** будут ходить в этот mock.

View File

@@ -80,6 +80,19 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
_ = json.NewEncoder(w).Encode(body)
}
func drainBody(r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
// logReq logs every request (step 5 in docs/local-gateway-mock.md).
func logReq(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("REQ %s %s", r.Method, r.URL.Path)
next(w, r)
}
}
func cleanupExpiredSessions(now time.Time) {
for uuid, session := range sessions {
if now.After(session.ExpiresAt) {
@@ -243,8 +256,7 @@ func handleServices(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
drainBody(r)
// Minimal shape for ApiServicesModel::updateModel + importFreeFromGateway (service_protocol "awg").
w.Header().Set("Content-Type", "application/json")
@@ -385,17 +397,124 @@ func handleRoot(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("local_gateway plaintext mock\nPOST /api/v1/generate_qr, /api/v1/scan_qr, /v1/services, /v1/config\n"))
_, _ = w.Write([]byte("local_gateway plaintext mock — full path list: tools/local_gateway/README.md\n"))
}
// POST /v1/account_info — same path as SubscriptionController::getAccountInfo (ApiAccountInfoModel::updateModel).
func handleAccountInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
// Keys match client/core/utils/constants/apiKeys.h (snake_case).
endDate := time.Now().UTC().AddDate(1, 0, 0).Format(time.RFC3339)
resp := map[string]any{
"active_device_count": 1,
"max_device_count": 5,
"subscription_end_date": endDate,
"subscription_description": "Local mock (tools/local_gateway)",
"is_renewal_available": false,
"supported_protocols": []string{"awg", "vless"},
"available_countries": []any{},
"issued_configs": []any{},
"support_info": map[string]any{
"telegram": "amnezia_support",
"email": "support@example.com",
"billing_email": "billing@example.com",
"website": "https://amnezia.org",
"website_name": "Amnezia",
},
}
writeJSON(w, http.StatusOK, resp)
}
// POST /v1/news — NewsController::fetchNews (empty list is fine).
func handleNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]any{"news": []any{}})
}
// POST /v1/renewal_link — SubscriptionController::getRenewalLink.
func handleRenewalLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]string{"renewal_url": "https://amnezia.org/"})
}
// POST /v1/updater_endpoint — UpdateController::fetchGatewayUrl, then GET {url}/VERSION.
func handleUpdaterEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]string{"url": "http://127.0.0.1:8080"})
}
// POST /v1/revoke_config, /v1/revoke_native_config — success body ignored if error is NoError.
func handleRevokeNoop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]string{"message": "mock"})
}
func handleGetVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("0.0.1"))
}
func handleGetChangelog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func handleGetReleaseDate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/", handleRoot)
http.HandleFunc("/v1/services", handleServices)
http.HandleFunc("/v1/config", handleConfig)
http.HandleFunc("/api/v1/generate_qr", handleGenerateQR)
http.HandleFunc("/api/v1/scan_qr", handleScanQR)
http.HandleFunc("/", logReq(handleRoot))
http.HandleFunc("/VERSION", logReq(handleGetVersion))
http.HandleFunc("/CHANGELOG", logReq(handleGetChangelog))
http.HandleFunc("/RELEASE_DATE", logReq(handleGetReleaseDate))
http.HandleFunc("/v1/account_info", logReq(handleAccountInfo))
http.HandleFunc("/v1/services", logReq(handleServices))
http.HandleFunc("/v1/config", logReq(handleConfig))
http.HandleFunc("/v1/news", logReq(handleNews))
http.HandleFunc("/v1/renewal_link", logReq(handleRenewalLink))
http.HandleFunc("/v1/updater_endpoint", logReq(handleUpdaterEndpoint))
http.HandleFunc("/v1/revoke_config", logReq(handleRevokeNoop))
http.HandleFunc("/v1/revoke_native_config", logReq(handleRevokeNoop))
http.HandleFunc("/api/v1/generate_qr", logReq(handleGenerateQR))
http.HandleFunc("/api/v1/scan_qr", logReq(handleScanQR))
const addr = "0.0.0.0:8080"
log.Printf("plaintext mock listening on tcp4 %s GET / POST /v1/services POST /v1/config POST /api/v1/generate_qr POST /api/v1/scan_qr\n", addr)
log.Printf("plaintext mock on tcp4 %s — see tools/local_gateway/README.md for paths\n", addr)
ln, err := net.Listen("tcp4", addr)
if err != nil {
log.Fatal(err)

33
tools/local_gateway/verify.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Smoke-test routes used by AmneziaVPN against a running local_gateway.
# Prerequisite: in another terminal: cd tools/local_gateway && go run .
# Usage: ./verify.sh [base_url] default: http://127.0.0.1:8080
set -euo pipefail
BASE="${1:-http://127.0.0.1:8080}"
echo "== GET / =="
curl -sfS "$BASE/" | head -n 2
echo "== GET updater follow-up =="
curl -sfS "$BASE/VERSION" | head -c 20
echo
curl -sfS -o /dev/null -w "CHANGELOG %{http_code}\n" "$BASE/CHANGELOG"
curl -sfS -o /dev/null -w "RELEASE_DATE %{http_code}\n" "$BASE/RELEASE_DATE"
echo "== POST /v1/* (empty JSON) =="
for path in account_info services config news renewal_link updater_endpoint revoke_config revoke_native_config; do
code=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$BASE/v1/$path" \
-H "Content-Type: application/json" -d '{}')
echo "POST /v1/$path -> HTTP $code"
if [[ "$code" != "200" ]]; then
echo "expected 200"; exit 1
fi
done
echo "== POST pairing bad payload -> 400 =="
code=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$BASE/api/v1/generate_qr" \
-H "Content-Type: application/json" -d '{}')
echo "POST /api/v1/generate_qr (invalid) -> HTTP $code"
[[ "$code" == "400" ]]
echo "OK"