mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
add test server
This commit is contained in:
158
tools/local_gateway/README.md
Normal file
158
tools/local_gateway/README.md
Normal file
@@ -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://<IP-это-машины>:8080/`.
|
||||
|
||||
Сервер поднимается через **`net.Listen("tcp4", "0.0.0.0:8080")`**, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LAN‑IP** (частая нестыковка IPv4/IPv6 у `ListenAndServe(":8080", …)`).
|
||||
|
||||
После `git pull` обязательно **остановите старый процесс** на 8080 (`Ctrl+C` в терминале или `kill <PID>`), иначе будет крутиться бинарник без правок.
|
||||
|
||||
В логах должно появиться сообщение вида:
|
||||
|
||||
`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
|
||||
```
|
||||
BIN
tools/local_gateway/gateway_plaintext_mock
Executable file
BIN
tools/local_gateway/gateway_plaintext_mock
Executable file
Binary file not shown.
5
tools/local_gateway/go.mod
Normal file
5
tools/local_gateway/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module gateway_plaintext_mock
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/dchest/captcha v1.1.0
|
||||
2
tools/local_gateway/go.sum
Normal file
2
tools/local_gateway/go.sum
Normal file
@@ -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=
|
||||
404
tools/local_gateway/main.go
Normal file
404
tools/local_gateway/main.go
Normal file
@@ -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))
|
||||
}
|
||||
25
tools/local_gateway/test.md
Normal file
25
tools/local_gateway/test.md
Normal file
@@ -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 живые.
|
||||
Reference in New Issue
Block a user