fixed 404, 1100, 1109 - fixed crash app (add server)

This commit is contained in:
dranik
2026-05-18 16:02:51 +03:00
parent b46a9e389f
commit 5eab5fc18b
14 changed files with 438 additions and 78 deletions
+2
View File
@@ -34,6 +34,8 @@ add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}")
add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}")
add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}")
include(../.cache/agw_rsa_public_keys.cmake)
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
set(PACKAGES ${PACKAGES} Widgets)
endif()
@@ -48,7 +48,7 @@ QFuture<QPair<ErrorCode, QJsonArray>> NewsController::fetchNews()
payload.insert(apiDefs::key::serviceType, stacksJson.value(apiDefs::key::serviceType));
}
auto future = gatewayController->postAsync(QString("%1v1/news"), payload);
auto future = gatewayController->postAsync(QString("%1v1/news"), payload, nullptr, gatewayController);
return future.then([gatewayController](QPair<ErrorCode, QByteArray> result) -> QPair<ErrorCode, QJsonArray> {
auto [errorCode, responseBody] = result;
if (errorCode != ErrorCode::NoError) {
@@ -108,6 +108,16 @@ ErrorCode interpretScanQrJson(const QJsonObject &obj)
}
} // namespace
QJsonArray PairingController::gatewayStringMetadataArray(const QString &value)
{
QJsonArray arr;
const QString trimmed = value.trimmed();
if (!trimmed.isEmpty()) {
arr.append(trimmed);
}
return arr;
}
ErrorCode PairingController::parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload)
{
outPayload = QrPairingConfigPayload {};
@@ -193,7 +203,7 @@ QJsonObject PairingController::buildScanQrPayload(const QString &qrUuid, const Q
o[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true);
o[apiDefs::key::appVersion] = QString(APP_VERSION);
o[apiDefs::key::osVersion] = QSysInfo::productType();
o[apiDefs::key::serviceType] = serviceType;
o[apiDefs::key::userCountryCode] = userCountryCode;
o[apiDefs::key::serviceType] = gatewayStringMetadataArray(serviceType);
o[apiDefs::key::userCountryCode] = gatewayStringMetadataArray(userCountryCode);
return o;
}
@@ -10,7 +10,7 @@
class SecureAppSettingsRepository;
/**
* Core API for QR pairing against Amnezia gateway (POST /api/v1/generate_qr, /api/v1/scan_qr).
* Core API for QR pairing against Amnezia gateway (POST /v1/generate_qr, /v1/scan_qr).
* Phase 1: transport via GatewayController, error mapping incl. gateway `http_status` wrapper and OpenAPI-style bodies.
*/
class PairingController
@@ -38,6 +38,8 @@ public:
static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey,
const QString &serviceType, const QString &userCountryCode);
static QJsonArray gatewayStringMetadataArray(const QString &value);
private:
SecureAppSettingsRepository *m_appSettingsRepository;
};
@@ -1146,7 +1146,7 @@ QFuture<QPair<ErrorCode, QString>> SubscriptionController::getRenewalLink(int se
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase),
apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled());
auto postFuture = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload);
auto postFuture = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload, nullptr, gatewayController);
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>();
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished,
[promise, watcher, gatewayController]() {
+142 -41
View File
@@ -10,6 +10,7 @@
#include <QJsonObject>
#include <QNetworkReply>
#include <QPromise>
#include <QTimer>
#include <QUrl>
#include "QBlockCipher.h"
@@ -25,6 +26,8 @@
#include "platforms/ios/ios_controller.h"
#endif
#include "embedded_agw_public_keys.h"
#ifdef AMNEZIA_DESKTOP
#include "core/utils/ipcClient.h"
#endif
@@ -57,7 +60,7 @@ namespace
constexpr int proxyStorageRequestTimeoutMsecs = 3000;
/** Pairing/subscription paths use "%1api/v1/..." / "%1v1/..." — %1 must end with '/' or the host and path merge (404). */
/** Gateway paths use "%1v1/..." — %1 must end with '/' or the host and path merge (404). */
QString normalizedGatewayBase(const QString &endpoint)
{
QString e = endpoint.trimmed();
@@ -178,6 +181,8 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co
QNetworkReply::NetworkError replyError, const QByteArray &key,
const QByteArray &iv, const QByteArray &salt)
{
Q_UNUSED(replyError);
DecryptionResult result;
result.decryptedBody = encryptedResponseBody;
result.isDecryptionSuccessful = false;
@@ -194,6 +199,29 @@ GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(co
return result;
}
GatewayController::DecryptionResult GatewayController::resolveResponseBody(const QByteArray &responseBody,
QNetworkReply::NetworkError replyError, const QByteArray &key,
const QByteArray &iv, const QByteArray &salt)
{
DecryptionResult result = tryDecryptResponseBody(responseBody, replyError, key, iv, salt);
if (result.isDecryptionSuccessful || !m_isDevEnvironment) {
return result;
}
const QByteArray trimmed = responseBody.trimmed();
if (trimmed.isEmpty() || trimmed.front() != '{') {
return result;
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(trimmed, &parseError);
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
result.decryptedBody = trimmed;
result.isDecryptionSuccessful = true;
}
return result;
}
ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody)
{
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
@@ -228,7 +256,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
}
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) {
@@ -244,7 +272,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
if (!sslErrors.isEmpty()
|| shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
@@ -275,11 +303,14 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api
}
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject &apiPayload,
QNetworkReply **activeReplyOut)
QNetworkReply **activeReplyOut,
const QSharedPointer<GatewayController> &keepAlive)
{
auto promise = QSharedPointer<QPromise<QPair<ErrorCode, QByteArray>>>::create();
promise->start();
const QSharedPointer<GatewayController> life = keepAlive;
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
if (encRequestData.errorCode != ErrorCode::NoError) {
promise->addResult(qMakePair(encRequestData.errorCode, QByteArray()));
@@ -296,7 +327,15 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
connect(reply, &QNetworkReply::sslErrors, [sslErrors](const QList<QSslError> &errors) { *sslErrors = errors; });
connect(reply, &QNetworkReply::finished, reply, [promise, sslErrors, encRequestData, endpoint, apiPayload, reply, this]() mutable {
connect(reply, &QNetworkReply::finished, reply,
[promise, sslErrors, encRequestData, endpoint, apiPayload, reply, life]() mutable {
if (!life) {
promise->addResult(qMakePair(ErrorCode::ApiConfigDecryptionError, QByteArray()));
promise->finish();
return;
}
GatewayController *const ctl = life.data();
QByteArray encryptedResponseBody = reply->readAll();
QString replyErrorString = reply->errorString();
auto replyError = reply->error();
@@ -317,7 +356,7 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
}
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
ctl->resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
auto processResponse = [promise, encRequestData](const GatewayController::DecryptionResult &decryptionResult,
const QList<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
@@ -342,13 +381,14 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
promise->finish();
};
if (sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
if (sslErrors->isEmpty()
&& ctl->shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
QStringList primaryBaseUrls;
QStringList fallbackBaseUrls;
if (m_isDevEnvironment) {
if (ctl->m_isDevEnvironment) {
primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
} else {
primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
@@ -375,16 +415,24 @@ QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString
appendStorageUrls(primaryBaseUrls, proxyStorageUrls);
appendStorageUrls(fallbackBaseUrls, proxyStorageUrls);
getProxyUrlsAsync(proxyStorageUrls, 0, [this, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) {
getProxyUrlAsync(proxyUrls, 0, [this, encRequestData, endpoint, processResponse](const QString &proxyUrl) {
bypassProxyAsync(endpoint, proxyUrl, encRequestData,
[processResponse, this](const QByteArray &decryptedBody, bool isDecryptionSuccessful,
const QList<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
const QString &replyErrorString, int httpStatusCode) {
life->getProxyUrlsAsync(life, proxyStorageUrls, 0,
[life, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) {
life->getProxyUrlAsync(life, proxyUrls, 0,
[life, encRequestData, endpoint, processResponse](
const QString &proxyUrl) {
life->bypassProxyAsync(
life, endpoint, proxyUrl, encRequestData,
[processResponse](const QByteArray &decryptedBody,
bool isDecryptionSuccessful,
const QList<QSslError> &sslErrors,
QNetworkReply::NetworkError replyError,
const QString &replyErrorString,
int httpStatusCode) {
GatewayController::DecryptionResult result;
result.decryptedBody = decryptedBody;
result.isDecryptionSuccessful = isDecryptionSuccessful;
processResponse(result, sslErrors, replyError, replyErrorString, httpStatusCode);
processResponse(result, sslErrors, replyError,
replyErrorString, httpStatusCode);
});
});
});
@@ -503,6 +551,11 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
bool isDecryptionSuccessful)
{
// Dev AGW is reached directly; S3 proxy rotation returns 500 and masks the real error (see pairing long-poll).
if (m_isDevEnvironment) {
return false;
}
const QByteArray &responseBody = decryptedResponseBody;
int apiHttpStatus = -1;
@@ -634,9 +687,14 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv
}
}
void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
std::function<void(const QStringList &)> onComplete)
void GatewayController::getProxyUrlsAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyStorageUrls,
const int currentProxyStorageIndex, const std::function<void(const QStringList &)> &onComplete)
{
if (!life) {
onComplete({});
return;
}
if (currentProxyStorageIndex >= proxyStorageUrls.size()) {
onComplete({});
return;
@@ -649,17 +707,23 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
QNetworkReply *reply = amnApp->networkManager()->get(request);
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) { *(state->sslErrors) = e; });
connect(reply, &QNetworkReply::finished, reply, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete, reply]() {
if (!life) {
onComplete({});
reply->deleteLater();
return;
}
GatewayController *const ctl = life.data();
connect(reply, &QNetworkReply::finished, this, [this, proxyStorageUrls, currentProxyStorageIndex, onComplete, reply]() {
if (reply->error() == QNetworkReply::NoError) {
QByteArray encrypted = reply->readAll();
reply->deleteLater();
QByteArray responseBody;
try {
QByteArray key = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
if (!m_isDevEnvironment) {
QByteArray key = ctl->m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
if (!ctl->m_isDevEnvironment) {
QCryptographicHash hash(QCryptographicHash::Sha512);
hash.addData(key);
QByteArray h = hash.result().toHex();
@@ -676,15 +740,21 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
} catch (...) {
Utils::logException();
qCritical() << "error decrypting payload";
QMetaObject::invokeMethod(
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection);
QTimer::singleShot(0, ctl, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete]() {
if (life) {
life->getProxyUrlsAsync(life, proxyStorageUrls, currentProxyStorageIndex + 1, onComplete);
} else {
onComplete({});
}
});
return;
}
QJsonArray endpointsArray = QJsonDocument::fromJson(responseBody).array();
QStringList endpoints;
for (const QJsonValue &endpoint : endpointsArray)
for (const QJsonValue &endpoint : endpointsArray) {
endpoints.push_back(endpoint.toString());
}
QStringList shuffled = endpoints;
std::random_device randomDevice;
@@ -699,16 +769,26 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co
qDebug() << httpStatusCode;
qDebug() << "go to the next storage endpoint";
reply->deleteLater();
QMetaObject::invokeMethod(
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, onComplete); }, Qt::QueuedConnection);
QTimer::singleShot(0, ctl, [life, proxyStorageUrls, currentProxyStorageIndex, onComplete]() {
if (life) {
life->getProxyUrlsAsync(life, proxyStorageUrls, currentProxyStorageIndex + 1, onComplete);
} else {
onComplete({});
}
});
});
}
void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex,
std::function<void(const QString &)> onComplete)
void GatewayController::getProxyUrlAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyUrls,
const int currentProxyIndex, const std::function<void(const QString &)> &onComplete)
{
if (!life) {
onComplete(QString());
return;
}
if (currentProxyIndex >= proxyUrls.size()) {
onComplete("");
onComplete(QString());
return;
}
@@ -719,13 +799,16 @@ void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int
QNetworkReply *reply = amnApp->networkManager()->get(request);
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) {
// *(state->sslErrors) = e;
// });
connect(reply, &QNetworkReply::finished, this, [this, proxyUrls, currentProxyIndex, onComplete, reply]() {
connect(reply, &QNetworkReply::finished, reply, [life, proxyUrls, currentProxyIndex, onComplete, reply]() {
reply->deleteLater();
if (!life) {
onComplete(QString());
return;
}
GatewayController *const ctl = life.data();
if (reply->error() == QNetworkReply::NoError) {
m_proxyUrl = proxyUrls[currentProxyIndex];
onComplete(m_proxyUrl);
@@ -733,15 +816,28 @@ void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int
}
qDebug() << "go to the next proxy endpoint";
QMetaObject::invokeMethod(this, [=]() { getProxyUrlAsync(proxyUrls, currentProxyIndex + 1, onComplete); }, Qt::QueuedConnection);
QTimer::singleShot(0, ctl, [life, proxyUrls, currentProxyIndex, onComplete]() {
if (life) {
life->getProxyUrlAsync(life, proxyUrls, currentProxyIndex + 1, onComplete);
} else {
onComplete(QString());
}
});
});
}
void GatewayController::bypassProxyAsync(
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete)
const QSharedPointer<GatewayController> &life, const QString &endpoint, const QString &proxyUrl,
const EncryptedRequestData &encRequestData,
const std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)>
&onComplete)
{
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
if (!life) {
onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, QStringLiteral("gateway gone"), 0);
return;
}
if (proxyUrl.isEmpty()) {
onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, "empty proxy url", 0);
return;
@@ -752,9 +848,9 @@ void GatewayController::bypassProxyAsync(
QNetworkReply *reply = amnApp->networkManager()->post(request, encRequestData.requestBody);
connect(reply, &QNetworkReply::sslErrors, this, [sslErrors](const QList<QSslError> &errors) { *sslErrors = errors; });
connect(reply, &QNetworkReply::sslErrors, reply, [sslErrors](const QList<QSslError> &errors) { *sslErrors = errors; });
connect(reply, &QNetworkReply::finished, this, [sslErrors, onComplete, encRequestData, reply, this]() {
connect(reply, &QNetworkReply::finished, reply, [life, sslErrors, onComplete, encRequestData, reply]() {
QByteArray encryptedResponseBody = reply->readAll();
QString replyErrorString = reply->errorString();
auto replyError = reply->error();
@@ -762,8 +858,13 @@ void GatewayController::bypassProxyAsync(
reply->deleteLater();
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
if (!life) {
onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, QStringLiteral("gateway gone"), 0);
return;
}
auto decryptionResult = life->resolveResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv,
encRequestData.salt);
onComplete(decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, *sslErrors, replyError, replyErrorString,
httpStatusCode);
+12 -6
View File
@@ -1,6 +1,8 @@
#ifndef GATEWAYCONTROLLER_H
#define GATEWAYCONTROLLER_H
#include <functional>
#include <QFuture>
#include <QNetworkReply>
#include <QObject>
@@ -27,7 +29,8 @@ public:
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
/** 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);
QNetworkReply **activeReplyOut = nullptr,
const QSharedPointer<GatewayController> &keepAlive = {});
private:
struct EncryptedRequestData
@@ -50,6 +53,8 @@ private:
EncryptedRequestData prepareRequest(const QString &endpoint, const QJsonObject &apiPayload);
DecryptionResult tryDecryptResponseBody(const QByteArray &encryptedResponseBody, QNetworkReply::NetworkError replyError,
const QByteArray &key, const QByteArray &iv, const QByteArray &salt);
DecryptionResult resolveResponseBody(const QByteArray &responseBody, QNetworkReply::NetworkError replyError, const QByteArray &key,
const QByteArray &iv, const QByteArray &salt);
QStringList getProxyUrls(const QString &serviceType, const QString &userCountryCode);
bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful);
@@ -57,12 +62,13 @@ private:
std::function<QNetworkReply *(const QString &url)> requestFunction,
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
std::function<void(const QStringList &)> onComplete);
void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function<void(const QString &)> onComplete);
void getProxyUrlsAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyStorageUrls, int currentProxyStorageIndex,
const std::function<void(const QStringList &)> &onComplete);
void getProxyUrlAsync(const QSharedPointer<GatewayController> &life, const QStringList &proxyUrls, int currentProxyIndex,
const std::function<void(const QString &)> &onComplete);
void bypassProxyAsync(
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete);
const QSharedPointer<GatewayController> &life, const QString &endpoint, const QString &proxyUrl, const EncryptedRequestData &encRequestData,
const std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> &onComplete);
int m_requestTimeoutMsecs;
QString m_gatewayEndpoint;
+18 -1
View File
@@ -57,6 +57,10 @@ void UpdateController::checkForUpdates()
if (m_updateCheckRunning || !m_appSettingsRepository) {
return;
}
if (m_appSettingsRepository->isDevGatewayEnv()) {
return;
}
m_updateCheckRunning = true;
fetchGatewayUrl();
@@ -93,6 +97,11 @@ void UpdateController::doGetAsync(const QString &endpoint, std::function<void(bo
void UpdateController::fetchGatewayUrl()
{
if (!m_appSettingsRepository || m_appSettingsRepository->isDevGatewayEnv()) {
finishUpdateCheck();
return;
}
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(),
m_appSettingsRepository->isDevGatewayEnv(),
7000,
@@ -105,11 +114,19 @@ void UpdateController::fetchGatewayUrl()
// Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.)
QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() {
gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload)
if (!m_appSettingsRepository || m_appSettingsRepository->isDevGatewayEnv()) {
finishUpdateCheck();
return;
}
gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload, nullptr, gatewayController)
.then(this, [this](QPair<ErrorCode, QByteArray> result) {
auto [err, gatewayResponse] = result;
if (err != ErrorCode::NoError) {
if (err == ErrorCode::ApiNotFoundError) {
logger.debug() << "Update check: updater_endpoint not found on gateway";
} else {
logger.error() << errorString(err);
}
finishUpdateCheck();
return;
}
+1 -1
View File
@@ -98,7 +98,7 @@ namespace amnezia
ApiNoPurchasedSubscriptionsError = 1115,
ApiTrialAlreadyUsedError = 1116,
// QR pairing (gateway /api/v1/generate_qr, /api/v1/scan_qr)
// QR pairing (gateway /v1/generate_qr, /v1/scan_qr)
ApiPairingForbiddenError = 1117,
ApiPairingConflictError = 1118,
ApiPairingRateLimitedError = 1119,
+13
View File
@@ -133,6 +133,19 @@ private slots:
ErrorCode::ApiPairingMissingMetadataError);
}
void gatewayStringMetadataArray_singleScalar()
{
const QJsonArray arr = PairingController::gatewayStringMetadataArray(QStringLiteral("amnezia-premium"));
QCOMPARE(arr.size(), 1);
QCOMPARE(arr.at(0).toString(), QStringLiteral("amnezia-premium"));
}
void gatewayStringMetadataArray_emptyForBlank()
{
QCOMPARE(PairingController::gatewayStringMetadataArray(QString()).size(), 0);
QCOMPARE(PairingController::gatewayStringMetadataArray(QStringLiteral(" ")).size(), 0);
}
void pairingUi_applyScanned_extractsUuid_emitsSignal()
{
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
@@ -25,14 +25,15 @@
#include "core/models/serverConfig.h"
#include "core/models/api/apiV2ServerConfig.h"
#include "core/utils/constants/apiConstants.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/qrCodeUtils.h"
using namespace amnezia;
namespace
{
constexpr auto kGenerateQrPath = "%1api/v1/generate_qr";
constexpr auto kScanQrPath = "%1api/v1/scan_qr";
constexpr auto kGenerateQrPath = "%1v1/generate_qr";
constexpr auto kScanQrPath = "%1v1/scan_qr";
constexpr auto kGatewayProbePath = "%1v1/news";
constexpr int kPairingRetryMaxAttempts = 3;
constexpr int kGatewayProbeTimeoutMsecs = 3000;
@@ -506,12 +507,27 @@ bool PairingUiController::canOpenTvQrPairingPage()
return false;
}
if (!m_serversController || m_serversController->gatewayStacks().isEmpty()) {
return true;
}
QJsonObject payload;
payload.insert(QStringLiteral("locale"), m_appSettingsRepository->getAppLanguage().name().split(QLatin1Char('_')).first());
const QJsonObject stacksJson = m_serversController->gatewayStacks().toJson();
if (stacksJson.contains(apiDefs::key::userCountryCode)) {
payload.insert(apiDefs::key::userCountryCode, stacksJson.value(apiDefs::key::userCountryCode));
}
if (stacksJson.contains(apiDefs::key::serviceType)) {
payload.insert(apiDefs::key::serviceType, stacksJson.value(apiDefs::key::serviceType));
}
const bool isTestPurchase = false;
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), kGatewayProbeTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled());
QByteArray responseBody;
const ErrorCode err = gatewayController.post(QString::fromLatin1(kGatewayProbePath), QJsonObject {}, responseBody);
const ErrorCode err = gatewayController.post(QString::fromLatin1(kGatewayProbePath), payload, responseBody);
if (err != ErrorCode::NoError) {
emit errorOccurred(err);
return false;
@@ -589,7 +605,7 @@ void PairingUiController::dispatchTvGenerateQrAttempt(quint64 generation, int re
const QJsonObject payload = m_pairingController->buildGenerateQrPayload(m_tvSessionUuid);
QNetworkReply *replyRaw = nullptr;
const QFuture<QPair<ErrorCode, QByteArray>> future =
gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw);
gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload, &replyRaw, gatewayController);
m_tvNetworkReply = replyRaw;
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
@@ -804,7 +820,7 @@ void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, cons
serviceType, userCountryCode);
QNetworkReply *replyRaw = nullptr;
const QFuture<QPair<ErrorCode, QByteArray>> future =
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw);
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw, gatewayController);
m_phoneNetworkReply = replyRaw;
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
+15
View File
@@ -0,0 +1,15 @@
# `vpn-deeplink-demo.html`
Проверка на **десктопе**: клик по странице ведёт на **`vpn://…`** → Amnezia должна открыться с предпросмотром импорта.
## Как проверить
1. Один раз **запусти установленный AmneziaVPN** (на Windows иначе в реестр не попадёт обработчик **`vpn`** — браузер не отдаст ссылку приложению).
2. **Открой** `vpn-deeplink-demo.html` в браузере как удобно: двойной клик, перетащить файл в окно, «Открыть файл».
3. Вставь в поле **`vpn://…`** (или хвост без `vpn://`) → кликни **«Открыть (ссылка)»** или **«Открыть (кнопка → location)»**.
Лог — на странице и в консоли DevTools.
**Chrome:** если с **`file://`** не показывается запрос «разрешить сайту открывать **vpn**» — открой **тот же файл** с любого **HTTPS**-адреса (внутренний стенд, свой хостинг; конкретный URL и порт не важны). С **`https://`** поведение как у обычного сайта с `tg://`.
Если тишина: не тот обработчик схемы `vpn` в системе, или в Chrome `chrome://settings/handlers`.
@@ -0,0 +1,150 @@
# Проверка `vpn://` на iOS для тестировщиков: Safari, Firefox и другие браузеры
Документ описывает **как проверять** диплинк вида `vpn://…` на iPhone/iPad и **почему** результат может отличаться между
**Safari** и **сторонними браузерами** (Firefox, Chrome и т.д.). Это **ожидаемое различие платформы и продукта браузера
**, а не признак того, что приложение Amnezia «не зарегистрировало» схему.
---
## 1. Что мы проверяем
| Термин | Смысл |
|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Custom URL scheme** | Нестандартная «ссылка», начинающаяся с `vpn://` (аналог `tg://`). |
| **Обработчик ОС** | iOS знает, какое приложение должно открыть `vpn://`, по данным из **Info.plist** установленного бандла Amnezia (ключ `CFBundleURLTypes` / схема `vpn`). |
| **Успешный тест** | После действия пользователя система **предлагает открыть Amnezia** или **сразу открывает** приложение и передаёт туда URI (или эквивалентное поведение, зафиксированное в чеклисте релиза). |
Если **`vpn://` открывается из Safari**, регистрация схемы в приложении на устройстве **как правило уже корректна**.
Отсутствие того же поведения в Firefox **не отменяет** этот вывод.
---
## 2. Почему Safari «работает», а Firefox может «не работать»
### 2.1. Роль Safari
**Safari** на iOS — **системный** браузер от Apple. При навигации на URL с нестандартной схемой он опирается на *
*стандартные механизмы iOS**: зарегистрированные приложения, диалоги подтверждения (если включены), передачу URI в
выбранное приложение.
Типичные сценарии, где Safari ведёт себя предсказуемо:
- вставка `vpn://…` в **адресную строку** и переход;
- тап по **кликабельной** ссылке `vpn://…` на странице;
- открытие ссылки из **Заметок**, **Сообщений**, **Почты** (часто тоже через системный обработчик).
### 2.2. Роль Firefox (и других сторонних браузеров)
**Firefox**, **Chrome** и др. на iOS — это **отдельные приложения** со своим UI и политиками. Они используют
WebKit (требование Apple), но:
- **не обязаны** повторять Safari один в один для вставки в омнибокс;
- могут **не вызывать** тот же системный путь «открыть зарегистрированное приложение по схеме»;
- могут интерпретировать `vpn://` как **поисковый запрос**, **блокировать** переход или показывать **другое** поведение
без диалога «Открыть в приложении».
Итог: **разное поведение Safari и Firefox на iOS для `vpn://` — нормальная ситуация** с точки зрения платформы. Это
ограничение/особенность **конкретного браузера**, а не доказательство отсутствия `CFBundleURLTypes` в Amnezia.
### 2.3. Важно для отчётов о дефектах
| Наблюдение | Как трактовать в баг-трекере |
|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `vpn://` открывает Amnezia из Safari | Схема на устройстве, скорее всего, **зарегистрирована**; базовый сценарий iOS **пройден**. |
| Тот же URI в Firefox не предлагает приложение | Скорее **ограничение/политика Firefox** (или способ ввода: только вставка в адресную строку). Не дублировать как «Amnezia не регистрирует vpn» без проверки через Safari. |
| Не работает ни в Safari, ни в других местах | Тогда имеет смысл **инсталляция/сборка/конфликт** (другая сборка, профиль, ограничения MDM и т.д.) — отдельное расследование. |
---
## 3. Подготовка к тесту
1. На тестовое устройство установлена **тестируемая сборка** Amnezia (тот же бандл, что и в релизных критериях).
2. Приложение **хотя бы один раз** запускали после установки (чтобы исключить редкие сценарии с неполной установкой).
3. Под рукой **валидный** тестовый URI, например из задачи/чеклиста (формат `vpn://` + payload). **Не** публикуйте
боевые секреты в открытых отчётах; для регрессии достаточно **короткого тестового** payload по внутренним правилам
команды.
---
## 4. Чеклист: Safari (обязательный эталон на iOS)
Выполните по порядку и зафиксируйте результат (да/нет + версия iOS + версия приложения).
### 4.1. Вставка в адресную строку
1. Откройте **Safari**.
2. Тапните в **адресную строку**, вставьте полный `vpn://…`, нажмите **Перейти** / **Go**.
3. **Ожидаемо:** система или Safari предлагает открыть **Amnezia**, либо приложение открывается (в зависимости от
настроек и версии iOS).
### 4.2. Кликабельная ссылка на странице
1. Откройте в Safari страницу, где есть ссылка `<a href="vpn://…">` (например, внутренняя HTML-демо или стенд по HTTPS —
см. [README.md](README.md)).
2. Тап по ссылке (жест пользователя).
3. **Ожидаемо:** переход в Amnezia или системный запрос на открытие.
### 4.3. Заметки / Сообщения
1. Вставьте `vpn://…` в **Заметки** как ссылку или текст, который iOS распознаёт как ссылку, либо отправьте себе в *
*Сообщения**.
2. Тап по ссылке.
3. **Ожидаемо:** открытие Amnezia аналогично политике iOS для custom scheme.
**Если Safari-проверка успешна** — для истории «регистрация схемы на iOS» считаем **успешной** в типичном смысле QA.
---
## 5. Чеклист: Firefox на iOS (информативный, не эталон)
Цель — **зафиксировать фактическое поведение** продукта Mozilla, а не «починить» его со стороны Amnezia одним изменением
plist.
### 5.1. Вставка в адресную строку Firefox
1. Откройте **Firefox**.
2. Вставьте `vpn://…` в адресную строку, подтвердите переход.
3. **Допустимые исходы:** открытие Amnezia; отсутствие предложения; поиск; сообщение об ошибке — **всё это важно
записать** (версия Firefox, шаги, скрин/видео).
### 5.2. Ссылка на странице внутри Firefox
1. Откройте ту же HTTPS-страницу с демо-ссылкой, что и для Safari.
2. Тап по `vpn://` ссылке.
3. Сравните с Safari на **том же** устройстве и **той же** сборке Amnezia.
### 5.3. Как оформлять результат в отчёте
Заголовок вроде: **«iOS: Firefox не делегирует vpn:// в системное приложение (известное ограничение стороннего
браузера); Safari — OK»**.
Приложите: модель устройства, iOS, версия Firefox, версия Amnezia, **точные шаги** (вставка vs тап по ссылке).
---
## 6. Другие браузеры на iOS (кратко)
По тем же причинам поведение **Chrome**, **Edge**, **Opera** и т.д. может **отличаться** от Safari. Рекомендация для
единообразной регрессии:
- **Эталон:** Safari + системные приложения (Заметки, Сообщения по внутренним правилам).
- **Дополнительно:** 1–2 популярных сторонних браузера — как **информативная** матрица, без требования паритета с
Safari, если в спецификации продукта не оговорено иное.
---
## 7. Частые ловушки (чтобы не завести ложный баг)
1. **Только вставка в омнибокс** — у части браузеров это другой кодовый путь, чем тап по `<a href="vpn://">` на
странице. Всегда указывайте в отчёте **оба** варианта, если проверяли.
2. **HTTP vs HTTPS** для страницы с кнопкой — на десктопе и в некоторых сценариях политики жёстче к HTTPS; на iOS для *
*прямого** `vpn://` это вторично, но для демо-страницы лучше **HTTPS** (см. [README.md](README.md)).
3. **Копипаст с лишними пробелами/переносами** — URI должен быть **одной строкой** без обрезки.
4. **Другая сборка / TestFlight vs Debug** — убедитесь, что на устройстве именно та сборка, по которой ведёте учёт.
---
## 8. Продуктовый выход в будущем (не часть минимального теста `vpn://`)
Если нужно, чтобы ссылки **стабильно** открывали приложение **из любых браузеров и мессенджеров**, обычно переходят на *
*https-ссылки** и **Universal Links** (или свой лендинг с редиректом/кнопкой). Это **отдельный** объём (домен,
`apple-app-site-association`, настройки Xcode). Текущий документ описывает только **custom scheme `vpn://`** на iOS.
+32 -4
View File
@@ -53,6 +53,32 @@ type authData struct {
APIKey string `json:"api_key"`
}
// gatewayStringList accepts a JSON string or a non-empty string array (dev gateway contract).
type gatewayStringList []string
func (g *gatewayStringList) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*g = gatewayStringList{single}
return nil
}
var arr []string
if err := json.Unmarshal(data, &arr); err != nil {
return err
}
*g = arr
return nil
}
func (g gatewayStringList) firstNonEmpty() string {
for _, s := range g {
if t := strings.TrimSpace(s); t != "" {
return t
}
}
return ""
}
type scanQRRequest struct {
QRUUID string `json:"qr_uuid"`
Config string `json:"config"`
@@ -62,8 +88,8 @@ type scanQRRequest struct {
InstallationUUID string `json:"installation_uuid"`
AppVersion string `json:"app_version"`
OSVersion string `json:"os_version"`
ServiceType string `json:"service_type"`
UserCountryCode string `json:"user_country_code"`
ServiceType gatewayStringList `json:"service_type"`
UserCountryCode gatewayStringList `json:"user_country_code"`
}
type pairingResult struct {
@@ -161,8 +187,8 @@ func validateGenerateQRRequest(req generateQRRequest) bool {
}
func validateScanQRRequest(req scanQRRequest) bool {
st := strings.TrimSpace(req.ServiceType)
cc := strings.TrimSpace(req.UserCountryCode)
st := req.ServiceType.firstNonEmpty()
cc := req.UserCountryCode.firstNonEmpty()
return req.QRUUID != "" &&
req.Config != "" &&
req.ServiceInfo != nil &&
@@ -802,6 +828,8 @@ func main() {
http.HandleFunc("/v1/revoke_native_config", logReq(handleRevokeNoop))
http.HandleFunc("/api/v1/generate_qr", logReq(handleGenerateQR))
http.HandleFunc("/api/v1/scan_qr", logReq(handleScanQR))
http.HandleFunc("/v1/generate_qr", logReq(handleGenerateQR))
http.HandleFunc("/v1/scan_qr", logReq(handleScanQR))
logStartupURLs(listenAddr, portStr)