mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-06-24 02:00:24 +07:00
remove mock & temp var AMNEZIA_QR_PAIRING_ALLOW
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Local gateway: LAN / Wi‑Fi (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 Wi‑Fi 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 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://<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.
|
||||
@@ -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`/браузере на **LAN‑IPv4** (частая нестыковка 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.
@@ -1,5 +0,0 @@
|
||||
module gateway_plaintext_mock
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/dchest/captcha v1.1.0
|
||||
@@ -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=
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 живые.
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user