mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
fixed open Qr QML & add check error code & add test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,6 +103,7 @@ namespace amnezia
|
||||
ApiPairingConflictError = 1118,
|
||||
ApiPairingRateLimitedError = 1119,
|
||||
ApiPairingServiceUnavailableError = 1120,
|
||||
ApiPairingPayloadTooLargeError = 1121,
|
||||
|
||||
// QFile errors
|
||||
OpenError = 1200,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
126
client/tests/testPairingParsers.cpp
Normal file
126
client/tests/testPairingParsers.cpp
Normal 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"
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
33
tools/local_gateway/verify.sh
Executable 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"
|
||||
Reference in New Issue
Block a user