diff --git a/tools/local_gateway/README.md b/tools/local_gateway/README.md new file mode 100644 index 000000000..25fc4c2d3 --- /dev/null +++ b/tools/local_gateway/README.md @@ -0,0 +1,158 @@ +# 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 . +``` + +Сервер слушает **`0.0.0.0:8080`** (все IPv4‑интерфейсы): с этого Mac — `http://127.0.0.1:8080/`, с телефона в той же LAN — `http://:8080/`. + +Сервер поднимается через **`net.Listen("tcp4", "0.0.0.0:8080")`**, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LAN‑IP** (частая нестыковка IPv4/IPv6 у `ListenAndServe(":8080", …)`). + +После `git pull` обязательно **остановите старый процесс** на 8080 (`Ctrl+C` в терминале или `kill `), иначе будет крутиться бинарник без правок. + +В логах должно появиться сообщение вида: + +`plaintext mock listening on 0.0.0.0:8080 GET / POST /v1/services POST /v1/config POST /api/v1/generate_qr POST /api/v1/scan_qr` + +## Эндпоинты + +| Метод | Путь | Назначение | +|--------|------|------------| +| `GET` | `/` | Короткий текст для проверки из браузера / телефона. | +| `POST` | `/v1/services` | Минимальный ответ со списком сервисов (в т.ч. `amnezia-free` / `awg`). | +| `POST` | `/v1/config` | Импорт конфига: лимит/CAPTCHA (`dchest/captcha`), проверка решения, мок-ответы. | +| `POST` | `/api/v1/generate_qr` | Регистрация pairing-сессии по `qr_uuid` + long-poll (**120s** в этом mock; **30s** на production gateway). | +| `POST` | `/api/v1/scan_qr` | Завершение pairing-сессии: передача `config` + `service_info` + `supported_protocols` по `qr_uuid`. | + +Других маршрутов нет (кроме `GET /`). + +## Связка с клиентом AmneziaVPN + +1. Соберите клиент с флагом CMake **`AMNEZIA_LOCAL_GATEWAY=ON`** — тогда для `localhost` запросы к gateway уходят **plaintext JSON** без RSA/AES (см. `GatewayController`, `SecureAppSettingsRepository`). +2. В настройках приложения endpoint gateway должен указывать на **`http://localhost:8080/`** (или `http://127.0.0.1:8080/`). При включённом `AMNEZIA_LOCAL_GATEWAY` дефолтный URL в коде уже `http://localhost:8080/`. + +После этого сценарии вроде **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 +``` diff --git a/tools/local_gateway/gateway_plaintext_mock b/tools/local_gateway/gateway_plaintext_mock new file mode 100755 index 000000000..44f165c1e Binary files /dev/null and b/tools/local_gateway/gateway_plaintext_mock differ diff --git a/tools/local_gateway/go.mod b/tools/local_gateway/go.mod new file mode 100644 index 000000000..35b1e3edf --- /dev/null +++ b/tools/local_gateway/go.mod @@ -0,0 +1,5 @@ +module gateway_plaintext_mock + +go 1.21 + +require github.com/dchest/captcha v1.1.0 diff --git a/tools/local_gateway/go.sum b/tools/local_gateway/go.sum new file mode 100644 index 000000000..637f197a4 --- /dev/null +++ b/tools/local_gateway/go.sum @@ -0,0 +1,2 @@ +github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ= +github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go new file mode 100644 index 000000000..8ab6b057a --- /dev/null +++ b/tools/local_gateway/main.go @@ -0,0 +1,404 @@ +// Plaintext mock for AmneziaVPN client (CMake AMNEZIA_LOCAL_GATEWAY=ON + localhost DEV_AGW_ENDPOINT). +// 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" + "io" + "log" + "net" + "net/http" + "sync" + "time" + + "github.com/dchest/captcha" +) + +func shortID(id string) string { + if len(id) <= 10 { + return id + } + return id[:10] + "…" +} + +// Set to 5 to mimic "more than 5 requests per 24h". Set to 0 so the first amnezia-free request returns CAPTCHA (faster UI test). +const rateLimitExcessAfter = 0 + +var ( + mu sync.Mutex + requests = map[string][]time.Time{} // installation_uuid -> timestamps (sliding window simplified: count in session) + sessions = map[string]*pairingSession{} +) + +// Local dev only: gateway team agreed 120s for mock vs 30s production (task_1 docs). +const ( + pairingTTL = 120 * time.Second + longPollWaitLimit = 120 * time.Second +) + +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"` +} + +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"` +} + +type pairingResult struct { + Config string `json:"config"` + ServiceInfo map[string]any `json:"service_info"` + SupportedProto []string `json:"supported_protocols"` +} + +type pairingSession struct { + QRUUID string + ExpiresAt time.Time + Done chan struct{} + Result *pairingResult + Completed bool +} + +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 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 { + return req.QRUUID != "" && + req.Config != "" && + req.ServiceInfo != nil && + req.SupportedProto != nil && + req.AuthData.APIKey != "" && + req.InstallationUUID != "" && + req.AppVersion != "" && + req.OSVersion != "" +} + +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 +} + +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 + } + + session := &pairingSession{ + QRUUID: req.QRUUID, + ExpiresAt: time.Now().Add(pairingTTL), + Done: make(chan struct{}), + } + + mu.Lock() + cleanupExpiredSessions(time.Now()) + sessions[req.QRUUID] = session + mu.Unlock() + + log.Printf("pairing REGISTERED uuid=%s ttl=%s", shortID(req.QRUUID), pairingTTL) + + timer := time.NewTimer(longPollWaitLimit) + defer timer.Stop() + + select { + case <-session.Done: + mu.Lock() + result := session.Result + if sessions[req.QRUUID] == session { + delete(sessions, req.QRUUID) + } + mu.Unlock() + if result == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "message": "Internal Server Error: Pairing completed without payload.", + }) + return + } + writeJSON(w, http.StatusOK, result) + case <-timer.C: + mu.Lock() + if sessions[req.QRUUID] == session { + delete(sessions, req.QRUUID) + } + mu.Unlock() + writeJSON(w, http.StatusRequestTimeout, map[string]string{ + "message": "Request Timeout: No config received within the allowed time.", + }) + } +} + +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{ + "detail": "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, + } + session.Completed = true + close(session.Done) + mu.Unlock() + + log.Printf("pairing COMPLETED uuid=%s config_len=%d", shortID(req.QRUUID), len(req.Config)) + 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 + } + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + + // 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\nPOST /api/v1/generate_qr, /api/v1/scan_qr, /v1/services, /v1/config\n")) +} + +func main() { + http.HandleFunc("/", handleRoot) + http.HandleFunc("/v1/services", handleServices) + http.HandleFunc("/v1/config", handleConfig) + http.HandleFunc("/api/v1/generate_qr", handleGenerateQR) + http.HandleFunc("/api/v1/scan_qr", handleScanQR) + const addr = "0.0.0.0:8080" + log.Printf("plaintext mock listening on tcp4 %s GET / POST /v1/services POST /v1/config POST /api/v1/generate_qr POST /api/v1/scan_qr\n", addr) + ln, err := net.Listen("tcp4", addr) + if err != nil { + log.Fatal(err) + } + log.Fatal(http.Serve(ln, nil)) +} diff --git a/tools/local_gateway/test.md b/tools/local_gateway/test.md new file mode 100644 index 000000000..08cfe2a23 --- /dev/null +++ b/tools/local_gateway/test.md @@ -0,0 +1,25 @@ +В 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 живые.