mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-22 02:01:08 +07:00
fixed 404, 1100, 1109 - fixed crash app (add server)
This commit is contained in:
@@ -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]() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user