From ccf005eb9a642f960a255a1d2179caee650a051f Mon Sep 17 00:00:00 2001 From: Pavel Yaumenau Date: Wed, 22 Apr 2026 16:46:05 +0300 Subject: [PATCH] add test server --- tools/local_gateway/go.mod | 5 ++ tools/local_gateway/go.sum | 2 + tools/local_gateway/main.go | 174 ++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 tools/local_gateway/go.mod create mode 100644 tools/local_gateway/go.sum create mode 100644 tools/local_gateway/main.go 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..49da64f0f --- /dev/null +++ b/tools/local_gateway/main.go @@ -0,0 +1,174 @@ +// 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/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) +) + +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 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) + + 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": "Please solve the CAPTCHA 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", + }) +} + +func main() { + http.HandleFunc("/v1/services", handleServices) + http.HandleFunc("/v1/config", handleConfig) + log.Println("plaintext mock listening on :8080 POST /v1/services POST /v1/config") + log.Fatal(http.ListenAndServe(":8080", nil)) +}