diff --git a/client/core/controllers/api/pairingController.cpp b/client/core/controllers/api/pairingController.cpp index badfca753..e45cd34b5 100644 --- a/client/core/controllers/api/pairingController.cpp +++ b/client/core/controllers/api/pairingController.cpp @@ -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; } diff --git a/client/core/controllers/api/pairingController.h b/client/core/controllers/api/pairingController.h index 957192f37..e5a2b2ef1 100644 --- a/client/core/controllers/api/pairingController.h +++ b/client/core/controllers/api/pairingController.h @@ -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; diff --git a/client/core/utils/errorCodes.h b/client/core/utils/errorCodes.h index 2898a78be..74c4825f7 100644 --- a/client/core/utils/errorCodes.h +++ b/client/core/utils/errorCodes.h @@ -104,6 +104,7 @@ namespace amnezia ApiPairingRateLimitedError = 1119, ApiPairingServiceUnavailableError = 1120, ApiPairingPayloadTooLargeError = 1121, + ApiPairingMissingMetadataError = 1122, // QFile errors OpenError = 1200, diff --git a/client/core/utils/errorStrings.cpp b/client/core/utils/errorStrings.cpp index 0c82fd916..b71f2fe0e 100644 --- a/client/core/utils/errorStrings.cpp +++ b/client/core/utils/errorStrings.cpp @@ -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; diff --git a/client/tests/testPairingParsers.cpp b/client/tests/testPairingParsers.cpp index c94a79aab..c525dc7da 100644 --- a/client/tests/testPairingParsers.cpp +++ b/client/tests/testPairingParsers.cpp @@ -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); diff --git a/client/ui/controllers/api/pairingUiController.cpp b/client/ui/controllers/api/pairingUiController.cpp index bcf43a9e2..0ca63d263 100644 --- a/client/ui/controllers/api/pairingUiController.cpp +++ b/client/ui/controllers/api/pairingUiController.cpp @@ -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> 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>::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; } diff --git a/client/ui/controllers/api/pairingUiController.h b/client/ui/controllers/api/pairingUiController.h index f77cc3060..af516aa03 100644 --- a/client/ui/controllers/api/pairingUiController.h +++ b/client/ui/controllers/api/pairingUiController.h @@ -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 {}; diff --git a/tools/local_gateway/LAN_GATEWAY.md b/tools/local_gateway/LAN_GATEWAY.md index 598d9bf3a..03b4eb6a5 100644 --- a/tools/local_gateway/LAN_GATEWAY.md +++ b/tools/local_gateway/LAN_GATEWAY.md @@ -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://: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 user’s LAN mock URL under **test purchase** (same idea as loopback). | Configure iOS dev build with both options, set gateway in app to **`http://:8080/`** (trailing slash as today). diff --git a/tools/local_gateway/README.md b/tools/local_gateway/README.md index 3958ef546..174e137af 100644 --- a/tools/local_gateway/README.md +++ b/tools/local_gateway/README.md @@ -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. diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go index 619b020ba..c437c8aed 100644 --- a/tools/local_gateway/main.go +++ b/tools/local_gateway/main.go @@ -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://: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()