update updated_spec.yaml

This commit is contained in:
dranik
2026-05-13 12:48:06 +03:00
parent 1baa2d85bd
commit 8a29b49fd7
10 changed files with 120 additions and 70 deletions
@@ -15,6 +15,8 @@ namespace
constexpr qsizetype kPairingMaxQrUuidChars = 128;
constexpr qsizetype kPairingMaxVpnConfigChars = 256 * 1024;
constexpr qsizetype kPairingMaxApiKeyChars = 8192;
constexpr qsizetype kPairingMaxServiceTypeChars = 64;
constexpr qsizetype kPairingMaxUserCountryCodeChars = 32;
ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload)
{
@@ -132,7 +134,8 @@ ErrorCode PairingController::parseScanQrResponseBody(const QByteArray &responseB
return ErrorCode::NoError;
}
ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey)
ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey,
const QString &serviceType, const QString &userCountryCode)
{
if (qrUuid.size() > kPairingMaxQrUuidChars) {
return ErrorCode::ApiConfigEmptyError;
@@ -143,6 +146,14 @@ ErrorCode PairingController::validatePairingScanFields(const QString &qrUuid, co
if (apiKey.size() > kPairingMaxApiKeyChars) {
return ErrorCode::ApiPairingPayloadTooLargeError;
}
const QString st = serviceType.trimmed();
const QString cc = userCountryCode.trimmed();
if (st.isEmpty() || cc.isEmpty()) {
return ErrorCode::ApiPairingMissingMetadataError;
}
if (st.size() > kPairingMaxServiceTypeChars || cc.size() > kPairingMaxUserCountryCodeChars) {
return ErrorCode::ApiPairingPayloadTooLargeError;
}
return ErrorCode::NoError;
}
@@ -153,7 +164,7 @@ PairingController::PairingController(SecureAppSettingsRepository *appSettingsRep
int PairingController::pairingLongPollTimeoutMsecs() const
{
return 30 * 1000;
return 60 * 1000;
}
QJsonObject PairingController::buildGenerateQrPayload(const QString &qrUuid) const
@@ -167,7 +178,8 @@ QJsonObject PairingController::buildGenerateQrPayload(const QString &qrUuid) con
}
QJsonObject PairingController::buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, const QString &apiKey) const
const QJsonArray &supportedProtocols, const QString &apiKey,
const QString &serviceType, const QString &userCountryCode) const
{
QJsonObject auth;
auth[apiDefs::key::apiKey] = apiKey;
@@ -181,5 +193,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;
return o;
}
@@ -29,13 +29,14 @@ public:
QJsonObject buildGenerateQrPayload(const QString &qrUuid) const;
QJsonObject buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo,
const QJsonArray &supportedProtocols, const QString &apiKey) const;
const QJsonArray &supportedProtocols, const QString &apiKey, const QString &serviceType,
const QString &userCountryCode) const;
static amnezia::ErrorCode parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload);
static amnezia::ErrorCode parseScanQrResponseBody(const QByteArray &responseBody, QString *outOptionalDisplayName = nullptr);
/** Length bounds before `scan_qr` (avoids huge JSON / abuse). */
static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey);
static amnezia::ErrorCode validatePairingScanFields(const QString &qrUuid, const QString &vpnConfig, const QString &apiKey,
const QString &serviceType, const QString &userCountryCode);
private:
SecureAppSettingsRepository *m_appSettingsRepository;
+1
View File
@@ -104,6 +104,7 @@ namespace amnezia
ApiPairingRateLimitedError = 1119,
ApiPairingServiceUnavailableError = 1120,
ApiPairingPayloadTooLargeError = 1121,
ApiPairingMissingMetadataError = 1122,
// QFile errors
OpenError = 1200,
+1
View File
@@ -88,6 +88,7 @@ QString errorString(ErrorCode code) {
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;
case (ErrorCode::ApiPairingMissingMetadataError): errorMessage = QObject::tr("This subscription is missing data required to transfer via QR (service type or country). Refresh the subscription or pick another server."); break;
// QFile errors
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;
+12 -2
View File
@@ -112,17 +112,27 @@ private slots:
{
QString vpnKey;
vpnKey.fill(QLatin1Char('x'), 256 * 1024 + 1);
QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), vpnKey, QStringLiteral("k")),
QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), vpnKey, QStringLiteral("k"),
QStringLiteral("amnezia-premium"), QStringLiteral("ru")),
ErrorCode::ApiPairingPayloadTooLargeError);
}
void validateScanFields_uuidTooLong()
{
QString uuid(200, QLatin1Char('a'));
QCOMPARE(PairingController::validatePairingScanFields(uuid, QStringLiteral("vpn://a"), QStringLiteral("k")),
QCOMPARE(PairingController::validatePairingScanFields(uuid, QStringLiteral("vpn://a"), QStringLiteral("k"),
QStringLiteral("amnezia-premium"), QStringLiteral("ru")),
ErrorCode::ApiConfigEmptyError);
}
void validateScanFields_missingServiceType()
{
QCOMPARE(PairingController::validatePairingScanFields(QStringLiteral("ab"), QStringLiteral("vpn://x"),
QStringLiteral("k"), QString(),
QStringLiteral("ru")),
ErrorCode::ApiPairingMissingMetadataError);
}
void pairingUi_applyScanned_extractsUuid_emitsSignal()
{
PairingUiController ctl(nullptr, nullptr, nullptr, nullptr);
@@ -757,7 +757,11 @@ void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIn
return;
}
const ErrorCode fieldErr = PairingController::validatePairingScanFields(trimmedUuid, vpnKey, apiKey);
const QString serviceType = apiV2->apiConfig.serviceType.trimmed();
const QString userCountryCode = apiV2->apiConfig.userCountryCode.trimmed();
const ErrorCode fieldErr =
PairingController::validatePairingScanFields(trimmedUuid, vpnKey, apiKey, serviceType, userCountryCode);
if (fieldErr != ErrorCode::NoError) {
emit errorOccurred(fieldErr);
return;
@@ -776,12 +780,13 @@ void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIn
setPhoneBusy(true);
dispatchPhoneScanQrAttempt(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
phoneGeneration, 0);
serviceType, userCountryCode, phoneGeneration, 0);
}
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)
const QString &apiKey, const QString &serviceType, const QString &userCountryCode,
quint64 generation, int retryAttempt)
{
if (!m_pairingController || !m_appSettingsRepository) {
return;
@@ -795,7 +800,8 @@ void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, cons
apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled());
const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey);
const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey,
serviceType, userCountryCode);
QNetworkReply *replyRaw = nullptr;
const QFuture<QPair<ErrorCode, QByteArray>> future =
gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload, &replyRaw);
@@ -805,7 +811,7 @@ void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, cons
m_phoneWatcher = watcher;
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::finished, this,
[this, gatewayController, watcher, generation, retryAttempt, qrUuid, isTestPurchase, vpnKey, serviceInfo,
supportedProtocols, apiKey]() {
supportedProtocols, apiKey, serviceType, userCountryCode]() {
Q_UNUSED(gatewayController);
const auto result = watcher->result();
watcher->deleteLater();
@@ -840,14 +846,14 @@ void PairingUiController::dispatchPhoneScanQrAttempt(const QString &qrUuid, cons
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);
});
QTimer::singleShot(delayMs, this, [this, qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols,
apiKey, serviceType, userCountryCode, generation, retryAttempt]() {
if (generation != m_phoneSessionGeneration) {
return;
}
dispatchPhoneScanQrAttempt(qrUuid, isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey,
serviceType, userCountryCode, generation, retryAttempt + 1);
});
return;
}
@@ -129,7 +129,8 @@ private:
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);
const QJsonArray &supportedProtocols, const QString &apiKey, const QString &serviceType,
const QString &userCountryCode, quint64 generation, int retryAttempt);
void setTvPairingUiPhase(int phase);
PairingController *m_pairingController {};
+2 -2
View File
@@ -20,7 +20,7 @@ This document is the **implementation checklist** for `tools/local_gateway` plus
| **`-listen` / `LOCAL_GATEWAY_LISTEN`** | Default `0.0.0.0:8080`; append `:8080` if port omitted. Still **`tcp4`** only (macOS LAN / curl oddities). |
| **`-public-base` / `LOCAL_GATEWAY_PUBLIC_BASE`** | Base URL **without** trailing slash; fed into **`POST /v1/updater_endpoint`** as `{"url":…}`. |
| **`-auto-public`** (default true) | If `public-base` empty, pick first **non-loopback IPv4** (prefers **private** / link-local over public). |
| **`-pairing-ttl` / `-long-poll` / `-rate-limit-excess-after`** | Replace hardcoded **120s** / **120s** / **0** for pairing and Free mock. |
| **`-pairing-ttl` / `-long-poll` / `-rate-limit-excess-after`** | Defaults **60s** / **60s** / **0** for pairing (aligned with `tmp/updated_spec.yaml`) and Free mock. |
| **Startup banner** | Logs listen address, chosen **`publicUpdaterBaseURL`**, and **every** `http://<ipv4>:port/` candidate per interface. |
| **Request logging** | `REQ start` (remote, method, path, query, UA, `X-Client-Request-ID`, content type/length) + `REQ end` (status, duration). |
| **Pairing logs** | Extra fields on register/complete (`installation_uuid` short, app/os, `config_len`, protocol count). |
@@ -51,7 +51,7 @@ Firewall: allow incoming TCP on the chosen port (e.g. **8080**) for **local subn
|------|--------|
| `NetworkUtilities::hostIsPrivateLanAddress` | Shared predicate for gateway + pairing + sandbox endpoint retention. |
| `GatewayController::prepareRequest` | Plaintext path for LAN when `AMNEZIA_LAN_PLAINTEXT_GATEWAY` is defined. |
| `PairingController::isLocalGatewayHost` | Treats LAN like mock for **120s** long-poll alignment with `tools/local_gateway`. |
| `PairingController::pairingLongPollTimeoutMsecs` | Client long-poll for `generate_qr` (**60s**) aligned with mock defaults. |
| `SecureAppSettingsRepository::getGatewayEndpoint(true)` | Keeps users LAN mock URL under **test purchase** (same idea as loopback). |
Configure iOS dev build with both options, set gateway in app to **`http://<mac-ip>:8080/`** (trailing slash as today).
+3 -3
View File
@@ -25,7 +25,7 @@ go run -buildvcs=false .
| `-listen` / `LOCAL_GATEWAY_LISTEN` | Адрес привязки, например `0.0.0.0:8080` (порт можно опустить — подставится `:8080`). |
| `-public-base` / `LOCAL_GATEWAY_PUBLIC_BASE` | Базовый URL **без** хвостового `/` для тела **`POST /v1/updater_endpoint`** (`url`). Нужен для **iOS по LAN** (иначе `127.0.0.1` указывает на сам телефон). |
| `-auto-public` (по умолчанию `true`) | Если `public-base` пуст, взять первый подходящий **не loopback** IPv4 и собрать `http://IP:порт`. |
| `-pairing-ttl`, `-long-poll` | TTL сессии QR и long-poll (по умолчанию **120s**, как раньше). |
| `-pairing-ttl`, `-long-poll` | TTL сессии QR и long-poll (по умолчанию **60s**, как в `tmp/updated_spec.yaml`; можно уменьшить для быстрых тестов). |
| `-rate-limit-excess-after` | Порог для мока Amnezia Free / CAPTCHA (по умолчанию **0**). |
Пример для iPhone + Mac:
@@ -64,8 +64,8 @@ bash verify.sh http://127.0.0.1:8080
| `POST` | `/v1/updater_endpoint` | `{"url":"…"}` — база из **`-public-base` / `LOCAL_GATEWAY_PUBLIC_BASE`** или **`-auto-public`**; затем клиент делает 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`. |
| `POST` | `/api/v1/generate_qr` | Pairing: long-poll (дефолт **60s**); повтор с тем же `qr_uuid` до истечения TTL **продолжает** ожидание (resume). |
| `POST` | `/api/v1/scan_qr` | Pairing: завершение по `qr_uuid`; тело должно включать **`service_type`** и **`user_country_code`** (как в спецификации). |
**Не реализовано** (нужен осмысленный `vpn://` / IAP): `POST /v1/trial`, `POST /v1/subscriptions`, `POST /v1/native_config`, `POST /v1/proxy_config` (Telegram). При необходимости — отдельная доработка или прод gateway.
+59 -43
View File
@@ -35,8 +35,8 @@ var (
issued = map[string]issuedConfigInfo{} // installation_uuid -> issued config info shown in /v1/account_info
// Configured from flags / env in main().
pairingSessionTTL = 30 * time.Second
longPollWaitLimit = 30 * time.Second
pairingSessionTTL = 60 * time.Second
longPollWaitLimit = 60 * time.Second
rateLimitExcessAfter = 0 // Set to 5 to mimic "more than 5 requests per 24h". 0 = first amnezia-free request may return CAPTCHA.
// No trailing slash; used by POST /v1/updater_endpoint so remote clients (e.g. iOS) poll the Mac, not 127.0.0.1 on-device.
publicUpdaterBaseURL string
@@ -62,6 +62,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"`
}
type pairingResult struct {
@@ -159,6 +161,8 @@ func validateGenerateQRRequest(req generateQRRequest) bool {
}
func validateScanQRRequest(req scanQRRequest) bool {
st := strings.TrimSpace(req.ServiceType)
cc := strings.TrimSpace(req.UserCountryCode)
return req.QRUUID != "" &&
req.Config != "" &&
req.ServiceInfo != nil &&
@@ -166,7 +170,9 @@ func validateScanQRRequest(req scanQRRequest) bool {
req.AuthData.APIKey != "" &&
req.InstallationUUID != "" &&
req.AppVersion != "" &&
req.OSVersion != ""
req.OSVersion != "" &&
st != "" &&
cc != ""
}
func pruneRequests(uuid string) {
@@ -186,6 +192,38 @@ func overLimit(uuid string) bool {
return len(requests[uuid]) > rateLimitExcessAfter
}
// waitGenerateQRResponse blocks until scan completes, errors, or long-poll timeout (matches client pairingLongPollTimeout).
func waitGenerateQRResponse(w http.ResponseWriter, session *pairingSession, qrUUID string) {
timer := time.NewTimer(longPollWaitLimit)
defer timer.Stop()
select {
case <-session.Done:
mu.Lock()
result := session.Result
if sessions[qrUUID] == session {
delete(sessions, qrUUID)
}
mu.Unlock()
if result == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"message": "Internal Server Error: Pairing completed without payload.",
})
return
}
writeJSON(w, http.StatusOK, result)
case <-timer.C:
mu.Lock()
if sessions[qrUUID] == session {
delete(sessions, qrUUID)
}
mu.Unlock()
writeJSON(w, http.StatusRequestTimeout, map[string]string{
"message": "Request Timeout: No config received within the allowed time.",
})
}
}
func handleGenerateQR(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
@@ -204,6 +242,21 @@ func handleGenerateQR(w http.ResponseWriter, r *http.Request) {
return
}
mu.Lock()
cleanupExpiredSessions(time.Now())
if ex, ok := sessions[req.QRUUID]; ok {
now := time.Now()
if now.After(ex.ExpiresAt) || ex.Completed {
delete(sessions, req.QRUUID)
} else {
sess := ex
mu.Unlock()
log.Printf("pairing RESUME uuid=%s install=%s", shortID(req.QRUUID), shortID(req.InstallationUUID))
waitGenerateQRResponse(w, sess, req.QRUUID)
return
}
}
session := &pairingSession{
QRUUID: req.QRUUID,
DesktopInstallUUID: req.InstallationUUID,
@@ -211,50 +264,13 @@ func handleGenerateQR(w http.ResponseWriter, r *http.Request) {
ExpiresAt: time.Now().Add(pairingSessionTTL),
Done: make(chan struct{}),
}
mu.Lock()
cleanupExpiredSessions(time.Now())
if _, exists := sessions[req.QRUUID]; exists {
mu.Unlock()
writeJSON(w, http.StatusConflict, map[string]string{
"message": "Conflict: QR session with this UUID already exists.",
})
return
}
sessions[req.QRUUID] = session
mu.Unlock()
log.Printf("pairing REGISTERED uuid=%s install=%s ttl=%s app=%s os=%s",
shortID(req.QRUUID), shortID(req.InstallationUUID), pairingSessionTTL, req.AppVersion, req.OSVersion)
timer := time.NewTimer(longPollWaitLimit)
defer timer.Stop()
select {
case <-session.Done:
mu.Lock()
result := session.Result
if sessions[req.QRUUID] == session {
delete(sessions, req.QRUUID)
}
mu.Unlock()
if result == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"message": "Internal Server Error: Pairing completed without payload.",
})
return
}
writeJSON(w, http.StatusOK, result)
case <-timer.C:
mu.Lock()
if sessions[req.QRUUID] == session {
delete(sessions, req.QRUUID)
}
mu.Unlock()
writeJSON(w, http.StatusRequestTimeout, map[string]string{
"message": "Request Timeout: No config received within the allowed time.",
})
}
waitGenerateQRResponse(w, session, req.QRUUID)
}
func handleScanQR(w http.ResponseWriter, r *http.Request) {
@@ -716,8 +732,8 @@ func main() {
publicFlag := flag.String("public-base", strings.TrimSpace(os.Getenv("LOCAL_GATEWAY_PUBLIC_BASE")),
"Base URL without trailing slash for /v1/updater_endpoint (required for iOS-on-LAN). Env: LOCAL_GATEWAY_PUBLIC_BASE")
autoPublic := flag.Bool("auto-public", true, "If public-base empty, derive http://<first-lan-ipv4>:port")
pairTTL := flag.Duration("pairing-ttl", 30*time.Second, "QR pairing session TTL")
longPoll := flag.Duration("long-poll", 30*time.Second, "Long-poll max wait for POST /api/v1/generate_qr")
pairTTL := flag.Duration("pairing-ttl", 60*time.Second, "QR pairing session TTL (align with tmp/updated_spec.yaml)")
longPoll := flag.Duration("long-poll", 60*time.Second, "Long-poll max wait for POST /api/v1/generate_qr")
rateN := flag.Int("rate-limit-excess-after", 0, "Amnezia Free: allow N requests per 24h window before rate-limit/CAPTCHA (0=tight)")
flag.Parse()