remove mock & temp var AMNEZIA_QR_PAIRING_ALLOW

This commit is contained in:
dranik
2026-05-18 17:57:57 +03:00
parent 5eab5fc18b
commit d8668742b4
13 changed files with 0 additions and 1380 deletions
-6
View File
@@ -34,8 +34,6 @@ 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()
@@ -204,10 +202,6 @@ list(APPEND SOURCES ${CMAKE_CURRENT_LIST_DIR}/main.cpp)
target_link_libraries(${PROJECT} PRIVATE ${LIBS})
target_compile_definitions(${PROJECT} PRIVATE "MZ_$<UPPER_CASE:${MZ_PLATFORM_NAME}>")
if(AMNEZIA_QR_PAIRING_ALLOW)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW)
endif()
target_sources(${PROJECT} PRIVATE ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC} ${I18NQRC})
# Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this).
@@ -114,22 +114,6 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
}
#endif
#ifdef AMNEZIA_QR_PAIRING_ALLOW
{
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
const QString host = gatewayUrl.host().toLower();
const bool loopback =
(host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1"));
// tools/local_gateway on a LAN IP (e.g. phone → Mac -auto-public): mock expects plaintext JSON.
const bool usePlaintext = loopback || NetworkUtilities::hostIsPrivateLanAddress(host);
if (usePlaintext) {
encRequestData.isPlaintextLocalGateway = true;
encRequestData.requestBody = QJsonDocument(apiPayload).toJson();
return encRequestData;
}
}
#endif
QSimpleCrypto::QBlockCipher blockCipher;
encRequestData.key = blockCipher.generatePrivateSalt(32);
encRequestData.iv = blockCipher.generatePrivateSalt(32);
@@ -17,12 +17,7 @@
using namespace amnezia;
namespace {
#ifdef AMNEZIA_QR_PAIRING_ALLOW
// Prefer 127.0.0.1 with local mock (tools/local_gateway listens on 0.0.0.0:8080); avoids LAN/IPv6 ambiguity in dev.
constexpr char gatewayEndpoint[] = "http://127.0.0.1:8080/";
#else
constexpr char gatewayEndpoint[] = "http://gw.amnezia.org:80/";
#endif
}
SecureAppSettingsRepository::SecureAppSettingsRepository(SecureQSettings* settings, QObject *parent)
@@ -261,14 +256,6 @@ QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) con
|| base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) {
return m_gatewayEndpoint;
}
#ifdef AMNEZIA_QR_PAIRING_ALLOW
{
const QUrl gatewayUrl(base);
if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) {
return m_gatewayEndpoint;
}
}
#endif
return QString(DEV_AGW_ENDPOINT);
}
return m_gatewayEndpoint;
-15
View File
@@ -1,15 +0,0 @@
# `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`.
@@ -1,150 +0,0 @@
# Проверка `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.
-77
View File
@@ -1,77 +0,0 @@
# Local gateway: LAN / WiFi (macOS host ↔ iOS client)
This document is the **implementation checklist** for `tools/local_gateway` plus the **AmneziaVPN client** dev flags used with a **plaintext** mock over **private LAN** addresses (not only `127.0.0.1`).
## Goals
1. Run **`tools/local_gateway`** on a Mac; reach it from an iPhone on the same WiFi via **`http://<mac-lan-ip>:<port>/`**.
2. **`POST /v1/updater_endpoint`** must return a **`url`** the phone can reach (not `http://127.0.0.1:8080`, which points at the phone itself).
3. **Verbose logs** on the server for debugging.
4. **Client:** plaintext JSON to the mock only for **loopback** by default; **optional** plaintext to **RFC1918 / ULA / link-local** hosts when **`AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON`** (requires **`AMNEZIA_QR_PAIRING_ALLOW=ON`**).
> **Security:** `AMNEZIA_LAN_PLAINTEXT_GATEWAY` disables transport encryption to any configured gateway whose host parses as a private LAN address. Use **only** in internal dev builds with `tools/local_gateway`, never for production endpoints.
---
## Phase A — Go server (`main.go`)
| Item | Status |
|------|--------|
| **`-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`** | 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). |
### Example: Mac + iPhone
```bash
cd tools/local_gateway
# Explicit base (safest if you have several NICs):
LOCAL_GATEWAY_PUBLIC_BASE='http://192.168.1.10:8080' go run -buildvcs=false .
# Or rely on auto-public (first suitable IPv4 + listen port):
go run -buildvcs=false .
```
Firewall: allow incoming TCP on the chosen port (e.g. **8080**) for **local subnet**.
---
## Phase B — Client (CMake + C++)
| CMake | Meaning |
|-------|---------|
| **`AMNEZIA_QR_PAIRING_ALLOW=ON`** | Enables QR pairing + **loopback** plaintext to `localhost` / `127.0.0.1` / `::1` (`GatewayController`). |
| **`AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON`** | **Also** sends plaintext JSON when gateway host is **private LAN** per `NetworkUtilities::hostIsPrivateLanAddress` (IPv4: `10/8`, `172.16/12`, `192.168/16`, `169.254/16`; IPv6: `fe80::/10`, `fc00::/7`). **Requires** `AMNEZIA_QR_PAIRING_ALLOW=ON` or CMake **fatal error**. |
| Code | Change |
|------|--------|
| `NetworkUtilities::hostIsPrivateLanAddress` | Shared predicate for gateway + pairing + sandbox endpoint retention. |
| `GatewayController::prepareRequest` | Plaintext path for LAN when `AMNEZIA_LAN_PLAINTEXT_GATEWAY` is defined. |
| `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).
---
## Phase C — Smoke test
```bash
cd tools/local_gateway
go run -buildvcs=false . &
sleep 1
bash verify.sh 'http://127.0.0.1:8080'
# or against LAN IP from another shell on same machine:
bash verify.sh 'http://192.168.1.10:8080'
```
---
## References
- `tools/local_gateway/README.md` — quick start and endpoint table.
- `docs/local-gateway-mock.md` — client wiring and checklist.
-194
View File
@@ -1,194 +0,0 @@
# Local gateway (plaintext mock)
Минимальный HTTP-сервер на Go, который имитирует ответы Amnezia API gateway **без шифрования**: те же JSON-тела, что клиент отправляет в зашифрованном виде на прод. Удобно для отладки UI (в том числе CAPTCHA) и сценария **Amnezia Free**.
## Требования
- [Go](https://go.dev/dl/) **1.21** или новее (см. `go.mod`).
## Запуск
Из каталога `tools/local_gateway`:
```bash
cd tools/local_gateway
go mod download
go run -buildvcs=false .
```
По умолчанию слушает **`0.0.0.0:8080`** (**`tcp4`** только — см. ниже). С Mac: `http://127.0.0.1:8080/`; с телефона в той же Wi‑Fi: `http://<LAN-IP-Mac>:8080/`.
**Флаги и переменные окружения** (полный чеклист: **[LAN_GATEWAY.md](./LAN_GATEWAY.md)**):
| Флаг / env | Назначение |
|------------|------------|
| `-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 (по умолчанию **60s**, как в `tmp/updated_spec.yaml`; можно уменьшить для быстрых тестов). |
| `-rate-limit-excess-after` | Порог для мока Amnezia Free / CAPTCHA (по умолчанию **0**). |
Пример для iPhone + Mac:
```bash
LOCAL_GATEWAY_PUBLIC_BASE='http://192.168.1.10:8080' go run -buildvcs=false .
```
`net.Listen("tcp4", …)` оставлен, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LANIPv4** (частая нестыковка IPv4/IPv6 у `ListenAndServe(":8080", …)`).
После `git pull` обязательно **остановите старый процесс** на порту (`Ctrl+C` или `kill <PID>`).
**Логи:** при старте печатается баннер с **`updater_endpoint` URL** и списком **`http://<ipv4>:порт/`** по интерфейсам. Каждый запрос: строки **`REQ start`** (remote addr, path, UA, `X-Client-Request-ID`, размер тела) и **`REQ end`** (HTTP status, длительность).
Проверка без клиента (mock должен быть запущен):
```bash
./verify.sh
# или
bash verify.sh http://127.0.0.1:8080
```
## Эндпоинты
| Метод | Путь | Назначение |
|--------|------|------------|
| `GET` | `/` | Короткий текст для проверки из браузера / телефона. |
| `GET` | `/VERSION` | Версия для цепочки обновлений (`UpdateController`: после `updater_endpoint`). Значение `0.0.1` — ниже клиента, «обновление не найдено». |
| `GET` | `/CHANGELOG` | Пустое тело, успех. |
| `GET` | `/RELEASE_DATE` | Пустое тело, успех. |
| `POST` | `/v1/account_info` | Экран API‑подписки (`getAccountInfo`). |
| `POST` | `/v1/services` | Каталог сервисов (`ServicesCatalogController`). |
| `POST` | `/v1/config` | Amnezia Free: CAPTCHA/лимит; иначе короткий мок‑ответ (полноценный premium `vpn://` здесь не строится). |
| `POST` | `/v1/news` | Лента новостей (`NewsController`), пустой `news`. |
| `POST` | `/v1/renewal_link` | Ссылка продления (`renewal_url`). |
| `POST` | `/v1/updater_endpoint` | `{"url":"…"}` — база из **`-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 (дефолт **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.
**Обновление premium** (`updateServiceFromGateway``POST /v1/config` с `amnezia-premium`) требует валидного поля `config` с `vpn://…` в ответе; текущий mock для premium не подменяет полный конфиг — избегайте «Reload API config» на полностью локальном стенде или расширяйте mock.
## Связка с клиентом AmneziaVPN
1. Включите **`AMNEZIA_QR_PAIRING_ALLOW=ON`** в CMake — тогда для **`127.0.0.1`**, **`localhost`**, **`::1`** запросы к gateway идут **plaintext JSON** без RSA/AES (`GatewayController`).
2. Для **iOS / другого хоста по LAN-IP** (`192.168.x.x` и т.п.) дополнительно включите **`AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON`** (требует `AMNEZIA_QR_PAIRING_ALLOW`). Иначе клиент шифрует тело как к прод gateway, mock его не поймёт. Подробности: **[LAN_GATEWAY.md](./LAN_GATEWAY.md)**.
3. В настройках приложения укажите endpoint: **`http://127.0.0.1:8080/`** или **`http://<LAN-IP>:8080/`** (с завершающим `/`, как принято в репозитории).
Пошаговый план (включая следующие этапы вроде `/v1/account_info`): **`docs/local-gateway-mock.md`**.
После этого сценарии вроде **Amnezia Free → Continue** будут ходить в этот mock.
Для QR pairing (локальная разработка до готовности реального gateway):
1. TV-клиент вызывает `POST /api/v1/generate_qr` и держит long-poll (до **120s** в mock).
2. Phone-клиент вызывает `POST /api/v1/scan_qr` с тем же `qr_uuid`.
3. Mock возвращает TV-клиенту `200` c `config`, `service_info`, `supported_protocols`.
Поведение кодов:
- `generate_qr`: `200`, `400`, `408`, `500`
- `scan_qr`: `200`, `400`, `403`, `404`, `409`
Примечания:
- сессии хранятся in-memory (без Redis), TTL = **120s** (локально); на проде ожидайте **30s**;
- `auth_data.api_key == "invalid"` -> `403`;
- повторный `scan_qr` по завершенной сессии -> `409`.
## Быстрые `curl`-сценарии для QR pairing
## 1) Happy path (два терминала)
Терминал A (TV: long-poll ожидание):
```bash
curl -i -X POST "http://127.0.0.1:8080/api/v1/generate_qr" \
-H "Content-Type: application/json" \
-d '{
"qr_uuid": "123e4567-e89b-12d3-a456-426614174000",
"installation_uuid": "tv-installation-001",
"app_version": "4.8.3.1",
"os_version": "Android TV 14"
}'
```
Терминал B (Phone: completion того же UUID):
```bash
curl -i -X POST "http://127.0.0.1:8080/api/v1/scan_qr" \
-H "Content-Type: application/json" \
-d '{
"qr_uuid": "123e4567-e89b-12d3-a456-426614174000",
"config": "vpn://AAAA_3icpVdtT-...",
"service_info": {
"ad_description": "Mock ad",
"ad_endpoint": "https://example.com",
"ad_header": "Try Premium",
"is_ad_visible": false
},
"supported_protocols": ["awg", "vless"],
"auth_data": {
"api_key": "valid-local-key"
},
"installation_uuid": "phone-installation-001",
"app_version": "4.8.3.1",
"os_version": "Android 14"
}'
```
Ожидаемо:
- в терминале B: `200 OK` + `{"message":"OK"}`
- в терминале A: `200 OK` + `config/service_info/supported_protocols`
## 2) Timeout path (`408`)
Вызовите только `generate_qr` и не отправляйте `scan_qr`:
```bash
curl -i -X POST "http://127.0.0.1:8080/api/v1/generate_qr" \
-H "Content-Type: application/json" \
-d '{
"qr_uuid": "123e4567-e89b-12d3-a456-426614174111",
"installation_uuid": "tv-installation-timeout",
"app_version": "4.8.3.1",
"os_version": "Android TV 14"
}'
```
Через ~**120** секунд вернется `408 Request Timeout` (в mock).
## 3) Ошибка авторизации (`403`)
```bash
curl -i -X POST "http://127.0.0.1:8080/api/v1/scan_qr" \
-H "Content-Type: application/json" \
-d '{
"qr_uuid": "123e4567-e89b-12d3-a456-426614174000",
"config": "vpn://AAAA_3icpVdtT-...",
"service_info": {"is_ad_visible": false},
"supported_protocols": ["awg"],
"auth_data": {"api_key": "invalid"},
"installation_uuid": "phone-installation-001",
"app_version": "4.8.3.1",
"os_version": "Android 14"
}'
```
Ожидаемо: `403 Forbidden`.
## Поведение CAPTCHA (для разработчика)
В `main.go` константа **`rateLimitExcessAfter`**: при `0` «лимит» срабатывает сразу и первый запрос к `/v1/config` для `amnezia-free` чаще возвращает ответ с CAPTCHA; большее значение имитирует N успешных запросов до CAPTCHA.
Опционально в теле `POST /v1/config` mock обрабатывает **`refresh_captcha": true`** (отдельная ветка в коде); кнопка «Обновить» в клиенте может повторно вызывать обычный импорт без этого поля — смотрите актуальную логику в `SubscriptionUiController`.
## Зависимости
- `github.com/dchest/captcha` — генерация и проверка картинки CAPTCHA.
После изменения зависимостей:
```bash
go mod tidy
```
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
module gateway_plaintext_mock
go 1.21
require github.com/dchest/captcha v1.1.0
-2
View File
@@ -1,2 +0,0 @@
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
-842
View File
@@ -1,842 +0,0 @@
// Plaintext mock for AmneziaVPN client (CMake AMNEZIA_QR_PAIRING_ALLOW; optional AMNEZIA_LAN_PLAINTEXT_GATEWAY for RFC1918 hosts).
// No RSA/AES — POST JSON is the same object the client sends inside api_payload when encrypted.
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/dchest/captcha"
)
func shortID(id string) string {
if len(id) <= 10 {
return id
}
return id[:10] + "…"
}
var (
mu sync.Mutex
requests = map[string][]time.Time{} // installation_uuid -> timestamps (sliding window simplified: count in session)
sessions = map[string]*pairingSession{}
issued = map[string]issuedConfigInfo{} // installation_uuid -> issued config info shown in /v1/account_info
// Configured from flags / env in main().
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
)
type generateQRRequest struct {
QRUUID string `json:"qr_uuid"`
InstallationUUID string `json:"installation_uuid"`
AppVersion string `json:"app_version"`
OSVersion string `json:"os_version"`
}
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"`
ServiceInfo map[string]any `json:"service_info"`
SupportedProto []string `json:"supported_protocols"`
AuthData authData `json:"auth_data"`
InstallationUUID string `json:"installation_uuid"`
AppVersion string `json:"app_version"`
OSVersion string `json:"os_version"`
ServiceType gatewayStringList `json:"service_type"`
UserCountryCode gatewayStringList `json:"user_country_code"`
}
type pairingResult struct {
Config string `json:"config"`
ServiceInfo map[string]any `json:"service_info"`
SupportedProto []string `json:"supported_protocols"`
}
type pairingSession struct {
QRUUID string
DesktopInstallUUID string
DesktopOSVersion string
ExpiresAt time.Time
Done chan struct{}
Result *pairingResult
Completed bool
}
type issuedConfigInfo struct {
InstallationUUID string `json:"installation_uuid"`
WorkerLastUpdated string `json:"worker_last_updated"`
LastDownloaded string `json:"last_downloaded"`
SourceType string `json:"source_type"`
OSVersion string `json:"os_version"`
ServerCountryCode string `json:"server_country_code"`
ServerCountryName string `json:"server_country_name"`
}
func stringFromMap(m map[string]any, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string)
return strings.TrimSpace(v)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func drainBody(r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
// statusResponseWriter captures HTTP status for access-style logging.
type statusResponseWriter struct {
http.ResponseWriter
status int
written bool
}
func (w *statusResponseWriter) WriteHeader(code int) {
if !w.written {
w.status = code
w.written = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusResponseWriter) Write(b []byte) (int, error) {
if !w.written {
w.status = http.StatusOK
w.written = true
}
return w.ResponseWriter.Write(b)
}
// logReq logs every request with remote addr, UA, and final status (docs/local-gateway-mock.md).
func logReq(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
srw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
log.Printf("REQ start remote=%s method=%s path=%s query=%s ua=%q x_client_request_id=%q content_type=%q content_length=%d",
r.RemoteAddr, r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("User-Agent"), r.Header.Get("X-Client-Request-ID"),
r.Header.Get("Content-Type"), r.ContentLength)
next(srw, r)
log.Printf("REQ end remote=%s method=%s path=%s status=%d dur=%s",
r.RemoteAddr, r.Method, r.URL.Path, srw.status, time.Since(start).Round(time.Millisecond))
}
}
func cleanupExpiredSessions(now time.Time) {
for uuid, session := range sessions {
if now.After(session.ExpiresAt) {
delete(sessions, uuid)
}
}
}
func validateGenerateQRRequest(req generateQRRequest) bool {
return req.QRUUID != "" && req.InstallationUUID != "" && req.AppVersion != "" && req.OSVersion != ""
}
func validateScanQRRequest(req scanQRRequest) bool {
st := req.ServiceType.firstNonEmpty()
cc := req.UserCountryCode.firstNonEmpty()
return req.QRUUID != "" &&
req.Config != "" &&
req.ServiceInfo != nil &&
req.SupportedProto != nil &&
req.AuthData.APIKey != "" &&
req.InstallationUUID != "" &&
req.AppVersion != "" &&
req.OSVersion != "" &&
st != "" &&
cc != ""
}
func pruneRequests(uuid string) {
now := time.Now()
cutoff := now.Add(-24 * time.Hour)
var kept []time.Time
for _, t := range requests[uuid] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
requests[uuid] = kept
}
func overLimit(uuid string) bool {
pruneRequests(uuid)
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)
return
}
var req generateQRRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "json", http.StatusBadRequest)
return
}
if !validateGenerateQRRequest(req) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"message": "Bad Request. The payload is missing required fields or contains invalid values.",
})
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,
DesktopOSVersion: req.OSVersion,
ExpiresAt: time.Now().Add(pairingSessionTTL),
Done: make(chan struct{}),
}
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)
waitGenerateQRResponse(w, session, req.QRUUID)
}
func handleScanQR(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
var req scanQRRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "json", http.StatusBadRequest)
return
}
if !validateScanQRRequest(req) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"message": "Bad Request. The payload is missing required fields or contains invalid values.",
})
return
}
// Keep compatibility with current gateway behavior: key problems are mapped to 403.
if req.AuthData.APIKey == "invalid" {
writeJSON(w, http.StatusForbidden, map[string]string{
"message": "Forbidden: Invalid API key or unauthorized request.",
})
return
}
mu.Lock()
cleanupExpiredSessions(time.Now())
session, ok := sessions[req.QRUUID]
if !ok || time.Now().After(session.ExpiresAt) {
mu.Unlock()
writeJSON(w, http.StatusNotFound, map[string]string{
"message": "Not Found: QR session not found or expired.",
})
return
}
if session.Completed {
mu.Unlock()
writeJSON(w, http.StatusConflict, map[string]string{
"message": "Conflict: Config already submitted for this QR session.",
})
return
}
session.Result = &pairingResult{
Config: req.Config,
ServiceInfo: req.ServiceInfo,
SupportedProto: req.SupportedProto,
}
nowISO := time.Now().UTC().Format(time.RFC3339)
countryCode := stringFromMap(req.ServiceInfo, "server_country_code")
if countryCode == "" {
countryCode = stringFromMap(req.ServiceInfo, "country_code")
}
if countryCode == "" {
countryCode = "ZZ"
}
countryName := stringFromMap(req.ServiceInfo, "server_country_name")
if countryName == "" {
countryName = stringFromMap(req.ServiceInfo, "country_name")
}
if countryName == "" {
countryName = "Mock Country"
}
desktopUUID := strings.TrimSpace(session.DesktopInstallUUID)
if desktopUUID == "" {
desktopUUID = strings.TrimSpace(req.InstallationUUID)
}
desktopOS := strings.TrimSpace(session.DesktopOSVersion)
if desktopOS == "" {
desktopOS = strings.TrimSpace(req.OSVersion)
}
issued[desktopUUID] = issuedConfigInfo{
InstallationUUID: desktopUUID,
WorkerLastUpdated: nowISO,
LastDownloaded: nowISO,
SourceType: "gateway_account",
OSVersion: desktopOS,
ServerCountryCode: countryCode,
ServerCountryName: countryName,
}
session.Completed = true
close(session.Done)
mu.Unlock()
log.Printf("pairing COMPLETED uuid=%s phone_install=%s desktop_install=%s config_len=%d proto_count=%d",
shortID(req.QRUUID), shortID(req.InstallationUUID), shortID(desktopUUID), len(req.Config), len(req.SupportedProto))
writeJSON(w, http.StatusOK, map[string]string{"message": "OK"})
}
func handleServices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
// Minimal shape for ApiServicesModel::updateModel + importFreeFromGateway (service_protocol "awg").
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"user_country_code": "ZZ",
"services": []map[string]any{
{
"service_type": "amnezia-free",
"service_protocol": "awg",
"service_info": map[string]any{},
"is_available": true,
"service_description": map[string]any{
"service_name": "Amnezia Free (mock)",
"card_description": "Local plaintext mock",
"description": "For CAPTCHA UI test only",
},
"available_countries": []any{},
},
},
})
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "json", http.StatusBadRequest)
return
}
st, _ := body["service_type"].(string)
if st != "amnezia-free" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"message": "mock: only amnezia-free"})
return
}
uuid, _ := body["installation_uuid"].(string)
if uuid == "" {
uuid = "anonymous"
}
captchaID, _ := body["captcha_id"].(string)
solution, _ := body["captcha_solution"].(string)
refresh, _ := body["refresh_captcha"].(bool)
if refresh {
var buf bytes.Buffer
id := captcha.NewLen(6)
_ = captcha.WriteImage(&buf, id, 240, 80)
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
log.Printf("captcha REFRESH id=%s uuid=%s", shortID(id), uuid)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"captcha_id": id,
"captcha_image": b64,
"hint": "Refreshed CAPTCHA",
})
return
}
if captchaID != "" && solution != "" {
if captcha.VerifyString(captchaID, solution) {
mu.Lock()
requests[uuid] = nil
mu.Unlock()
log.Printf("captcha VERIFIED id=%s uuid=%s (dchest.VerifyString ok) -> HTTP 200", shortID(captchaID), uuid)
// HTTP 200, no http_status:501 in body — client maps 501 to ApiUpdateRequestError ("update the app").
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"captcha_verified": true,
"message": "mock gateway: captcha ok — no vpn:// config in this mock (expect empty-config error in client)",
})
return
}
log.Printf("captcha REJECTED id=%s uuid=%s solution_len=%d (dchest.VerifyString failed) -> HTTP 402 invalid_captcha",
shortID(captchaID), uuid, len(solution))
var buf bytes.Buffer
id := captcha.NewLen(6)
_ = captcha.WriteImage(&buf, id, 240, 80)
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "invalid_captcha",
"captcha_id": id,
"captcha_image": b64,
"hint": "Try again",
})
return
}
mu.Lock()
requests[uuid] = append(requests[uuid], time.Now())
limit := overLimit(uuid)
mu.Unlock()
if limit {
var buf bytes.Buffer
id := captcha.NewLen(6)
_ = captcha.WriteImage(&buf, id, 240, 80)
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
log.Printf("captcha ISSUED id=%s uuid=%s (402 rate_limit_exceeded)", shortID(id), uuid)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "rate_limit_exceeded",
"captcha_id": id,
"captcha_image": b64,
"hint": "Enter the digits from the image to continue",
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "mock: under rate limit, no config payload",
})
}
// GET / — smoke test from a phone browser; avoids macOS oddities with IPv6 *:8080 + curl to own LAN IP.
func handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("local_gateway plaintext mock — full path list: tools/local_gateway/README.md\n"))
}
// POST /v1/account_info — same path as SubscriptionController::getAccountInfo (ApiAccountInfoModel::updateModel).
func handleAccountInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
mu.Lock()
gatewayConfigs := make([]issuedConfigInfo, 0, len(issued))
for _, cfg := range issued {
gatewayConfigs = append(gatewayConfigs, cfg)
}
mu.Unlock()
sort.Slice(gatewayConfigs, func(i, j int) bool {
return gatewayConfigs[i].InstallationUUID < gatewayConfigs[j].InstallationUUID
})
// Seed country_config rows so the client can verify "Configuration Files: N" (ApiAccountInfoModel counts these).
// active_device_count must reflect gateway devices only, not these synthetic file rows.
nowISO := time.Now().UTC().Format(time.RFC3339)
mockCountryConfigs := []issuedConfigInfo{
{
InstallationUUID: "mock-country-config-de",
WorkerLastUpdated: nowISO,
LastDownloaded: nowISO,
SourceType: "country_config",
OSVersion: "",
ServerCountryCode: "de",
ServerCountryName: "Germany",
},
{
InstallationUUID: "mock-country-config-nl",
WorkerLastUpdated: nowISO,
LastDownloaded: nowISO,
SourceType: "country_config",
OSVersion: "",
ServerCountryCode: "nl",
ServerCountryName: "Netherlands",
},
}
allIssued := append(append([]issuedConfigInfo{}, gatewayConfigs...), mockCountryConfigs...)
// Keys match client/core/utils/constants/apiKeys.h (snake_case).
endDate := time.Now().UTC().AddDate(1, 0, 0).Format(time.RFC3339)
resp := map[string]any{
"active_device_count": len(gatewayConfigs),
"max_device_count": 5,
"subscription_end_date": endDate,
"subscription_description": "Local mock (tools/local_gateway)",
"is_renewal_available": false,
"supported_protocols": []string{"awg", "vless"},
"available_countries": []any{},
"issued_configs": allIssued,
"support_info": map[string]any{
"telegram": "amnezia_support",
"email": "support@example.com",
"billing_email": "billing@example.com",
"website": "https://amnezia.org",
"website_name": "Amnezia",
},
}
writeJSON(w, http.StatusOK, resp)
}
// POST /v1/news — NewsController::fetchNews (empty list is fine).
func handleNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]any{"news": []any{}})
}
// POST /v1/renewal_link — SubscriptionController::getRenewalLink.
func handleRenewalLink(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]string{"renewal_url": "https://amnezia.org/"})
}
// POST /v1/updater_endpoint — UpdateController::fetchGatewayUrl, then GET {url}/VERSION.
func handleUpdaterEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
log.Printf("updater_endpoint response url=%q", publicUpdaterBaseURL)
writeJSON(w, http.StatusOK, map[string]string{"url": publicUpdaterBaseURL})
}
// POST /v1/revoke_config, /v1/revoke_native_config — success body ignored if error is NoError.
func handleRevokeNoop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
drainBody(r)
writeJSON(w, http.StatusOK, map[string]string{"message": "mock"})
}
func handleGetVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("0.0.1"))
}
func handleGetChangelog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func handleGetReleaseDate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func envOrDefault(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func cloneIPv4(ip net.IP) net.IP {
x := make(net.IP, 4)
copy(x, ip.To4())
return x
}
// pickLANIPv4 returns a stable choice of non-loopback IPv4 for updater_endpoint / banners (prefers private ULA space).
func pickLANIPv4() net.IP {
ifaces, err := net.Interfaces()
if err != nil {
log.Printf("net.Interfaces: %v", err)
return nil
}
type cand struct {
ip net.IP
private bool
name string
}
var cands []cand
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
if iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
priv := ip4.IsPrivate() || ip4.IsLinkLocalUnicast()
cands = append(cands, cand{ip: cloneIPv4(ip4), private: priv, name: iface.Name})
log.Printf("iface candidate name=%s ip=%s private_or_linklocal=%v", iface.Name, ip4, priv)
}
}
if len(cands) == 0 {
return nil
}
sort.SliceStable(cands, func(i, j int) bool {
if cands[i].private != cands[j].private {
return cands[i].private
}
if cands[i].name != cands[j].name {
return cands[i].name < cands[j].name
}
return bytes.Compare(cands[i].ip, cands[j].ip) < 0
})
chosen := cands[0].ip
log.Printf("pickLANIPv4: using %s (iface_hint=%s)", chosen, cands[0].name)
return chosen
}
func normalizePublicBase(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, "/")
return s
}
func logStartupURLs(listenAddr, portStr string) {
log.Printf("=== local_gateway (plaintext mock) ===")
log.Printf("listen tcp4: %s", listenAddr)
log.Printf("POST /v1/updater_endpoint will return: {\"url\": %q}", publicUpdaterBaseURL)
log.Printf("Point AmneziaVPN gateway setting to: %s/", publicUpdaterBaseURL)
log.Printf("Try from phone browser: %s/", publicUpdaterBaseURL)
log.Printf("Non-loopback IPv4 URLs (same listen port %s):", portStr)
ifaces, err := net.Interfaces()
if err != nil {
log.Printf(" (could not enumerate interfaces: %v)", err)
} else {
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := iface.Addrs()
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok {
continue
}
if ip4 := ipNet.IP.To4(); ip4 != nil && !ip4.IsLoopback() {
log.Printf(" http://%s:%s/ (iface %s)", ip4, portStr, iface.Name)
}
}
}
}
log.Printf("docs: tools/local_gateway/README.md tools/local_gateway/LAN_GATEWAY.md")
log.Printf("========================================")
}
func main() {
listenFlag := flag.String("listen", envOrDefault("LOCAL_GATEWAY_LISTEN", "0.0.0.0:8080"),
"TCP listen address (tcp4). Env: LOCAL_GATEWAY_LISTEN")
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", 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()
listenAddr := strings.TrimSpace(*listenFlag)
if _, _, err := net.SplitHostPort(listenAddr); err != nil {
listenAddr = net.JoinHostPort(listenAddr, "8080")
}
_, portStr, err := net.SplitHostPort(listenAddr)
if err != nil {
log.Fatalf("listen address: %v", err)
}
pairingSessionTTL = *pairTTL
longPollWaitLimit = *longPoll
rateLimitExcessAfter = *rateN
pub := normalizePublicBase(*publicFlag)
if pub == "" && *autoPublic {
if ip := pickLANIPv4(); ip != nil {
pub = fmt.Sprintf("http://%s:%s", ip.String(), portStr)
log.Printf("auto-public: updater + docs base -> %s (override with -public-base or LOCAL_GATEWAY_PUBLIC_BASE)", pub)
}
}
if pub == "" {
pub = fmt.Sprintf("http://127.0.0.1:%s", portStr)
log.Printf("WARN: public-base not set and auto-public found no LAN IPv4; using %s (broken for remote phones). Set -public-base or LOCAL_GATEWAY_PUBLIC_BASE.", pub)
}
publicUpdaterBaseURL = pub
http.HandleFunc("/", logReq(handleRoot))
http.HandleFunc("/VERSION", logReq(handleGetVersion))
http.HandleFunc("/CHANGELOG", logReq(handleGetChangelog))
http.HandleFunc("/RELEASE_DATE", logReq(handleGetReleaseDate))
http.HandleFunc("/v1/account_info", logReq(handleAccountInfo))
http.HandleFunc("/v1/services", logReq(handleServices))
http.HandleFunc("/v1/config", logReq(handleConfig))
http.HandleFunc("/v1/news", logReq(handleNews))
http.HandleFunc("/v1/renewal_link", logReq(handleRenewalLink))
http.HandleFunc("/v1/updater_endpoint", logReq(handleUpdaterEndpoint))
http.HandleFunc("/v1/revoke_config", logReq(handleRevokeNoop))
http.HandleFunc("/v1/revoke_native_config", logReq(handleRevokeNoop))
http.HandleFunc("/api/v1/generate_qr", logReq(handleGenerateQR))
http.HandleFunc("/api/v1/scan_qr", logReq(handleScanQR))
http.HandleFunc("/v1/generate_qr", logReq(handleGenerateQR))
http.HandleFunc("/v1/scan_qr", logReq(handleScanQR))
logStartupURLs(listenAddr, portStr)
ln, err := net.Listen("tcp4", listenAddr)
if err != nil {
log.Fatal(err)
}
log.Printf("listening tcp4 %s (actual %v)", listenAddr, ln.Addr())
log.Fatal(http.Serve(ln, nil))
}
-25
View File
@@ -1,25 +0,0 @@
В README мока уже есть curl. Логика такая:
Терминал A — как «TV», долгий запрос:
curl -i -N -X POST "http://127.0.0.1:8080/api/v1/generate_qr" \
-H "Content-Type: application/json" \
-d '{"qr_uuid":"123e4567-e89b-12d3-a456-426614174000","installation_uuid":"tv-install","app_version":"1.0","os_version":"test"}'
Терминал B — как «телефон», пока A висит:
curl -i -X POST "http://127.0.0.1:8080/api/v1/scan_qr" \
-H "Content-Type: application/json" \
-d '{
"qr_uuid":"123e4567-e89b-12d3-a456-426614174000",
"config":"vpn://test",
"service_info":{"is_ad_visible":false},
"supported_protocols":["awg"],
"auth_data":{"api_key":"valid-local-key"},
"installation_uuid":"phone-install",
"app_version":"1.0",
"os_version":"test"
}'
Ожидание: в B — 200 с {"message":"OK"}, в A — 200 с полями config / service_info / supported_protocols.
Так вы убеждаетесь, что мок и сценарий pairing живые.
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Smoke-test routes used by AmneziaVPN against a running local_gateway.
# Prerequisite: in another terminal: cd tools/local_gateway && go run .
# Usage: ./verify.sh [base_url] default: http://127.0.0.1:8080
set -euo pipefail
BASE="${1:-http://127.0.0.1:8080}"
echo "== local_gateway verify base: ${BASE} =="
echo "== GET / =="
curl -sfS "$BASE/" | head -n 2
echo "== GET updater follow-up =="
curl -sfS "$BASE/VERSION" | head -c 20
echo
curl -sfS -o /dev/null -w "CHANGELOG %{http_code}\n" "$BASE/CHANGELOG"
curl -sfS -o /dev/null -w "RELEASE_DATE %{http_code}\n" "$BASE/RELEASE_DATE"
echo "== POST /v1/* (empty JSON) =="
for path in account_info services config news renewal_link updater_endpoint revoke_config revoke_native_config; do
code=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$BASE/v1/$path" \
-H "Content-Type: application/json" -d '{}')
echo "POST /v1/$path -> HTTP $code"
if [[ "$code" != "200" ]]; then
echo "expected 200"; exit 1
fi
done
echo "== POST pairing bad payload -> 400 =="
code=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$BASE/api/v1/generate_qr" \
-H "Content-Type: application/json" -d '{}')
echo "POST /api/v1/generate_qr (invalid) -> HTTP $code"
[[ "$code" == "400" ]]
echo "OK"