From f65fd4a8c5cf0cf4f92d4e60f2d671b50ec742d4 Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 7 May 2026 22:30:18 +0300 Subject: [PATCH] fixed server go --- client/CMakeLists.txt | 8 + .../controllers/api/pairingController.cpp | 18 +- client/core/controllers/gatewayController.cpp | 8 +- .../secureAppSettingsRepository.cpp | 9 + client/core/utils/networkUtilities.cpp | 23 ++ client/core/utils/networkUtilities.h | 3 + tools/local_gateway/LAN_GATEWAY.md | 77 +++++++ tools/local_gateway/README.md | 33 ++- tools/local_gateway/main.go | 216 ++++++++++++++++-- tools/local_gateway/verify.sh | 2 + 10 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 tools/local_gateway/LAN_GATEWAY.md diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 5c847b774..c5d8b959f 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -214,6 +214,14 @@ if(AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY) target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY) endif() +option(AMNEZIA_LAN_PLAINTEXT_GATEWAY "Dev: plaintext JSON to private LAN gateway hosts (requires AMNEZIA_QR_PAIRING_ALLOW)" OFF) +if(AMNEZIA_LAN_PLAINTEXT_GATEWAY) + if(NOT AMNEZIA_QR_PAIRING_ALLOW) + message(FATAL_ERROR "AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON requires AMNEZIA_QR_PAIRING_ALLOW=ON") + endif() + target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LAN_PLAINTEXT_GATEWAY) +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). diff --git a/client/core/controllers/api/pairingController.cpp b/client/core/controllers/api/pairingController.cpp index 28c9dba23..63725658a 100644 --- a/client/core/controllers/api/pairingController.cpp +++ b/client/core/controllers/api/pairingController.cpp @@ -2,12 +2,14 @@ #include #include +#include #include "core/controllers/gatewayController.h" #include "core/repositories/secureAppSettingsRepository.h" #include "core/utils/api/apiUtils.h" #include "core/utils/constants/apiConstants.h" #include "core/utils/constants/apiKeys.h" +#include "core/utils/networkUtilities.h" #include "version.h" using namespace amnezia; @@ -22,10 +24,18 @@ constexpr qsizetype kPairingMaxApiKeyChars = 8192; bool isLocalGatewayHost(const QString &gatewayUrl) { - return gatewayUrl.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive) - || gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive) - || gatewayUrl.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive) - || gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive); + if (gatewayUrl.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive) + || gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive) + || gatewayUrl.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive) + || gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive)) { + return true; + } +#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY + const QUrl u(gatewayUrl); + return NetworkUtilities::hostIsPrivateLanAddress(u.host()); +#else + return false; +#endif } ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload) diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 6d308912e..31f194a2d 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -94,7 +94,13 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const { const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl); const QString host = gatewayUrl.host().toLower(); - if (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")) { + bool usePlaintext = (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")); +#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY + if (!usePlaintext) { + usePlaintext = NetworkUtilities::hostIsPrivateLanAddress(host); + } +#endif + if (usePlaintext) { encRequestData.isPlaintextLocalGateway = true; encRequestData.requestBody = QJsonDocument(apiPayload).toJson(); return encRequestData; diff --git a/client/core/repositories/secureAppSettingsRepository.cpp b/client/core/repositories/secureAppSettingsRepository.cpp index 3d6a9e0ac..c1da2de40 100644 --- a/client/core/repositories/secureAppSettingsRepository.cpp +++ b/client/core/repositories/secureAppSettingsRepository.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "core/utils/errorCodes.h" #include "core/utils/routeModes.h" @@ -260,6 +261,14 @@ QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) con || base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) { return m_gatewayEndpoint; } +#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY + { + const QUrl gatewayUrl(base); + if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) { + return m_gatewayEndpoint; + } + } +#endif return QString(DEV_AGW_ENDPOINT); } return m_gatewayEndpoint; diff --git a/client/core/utils/networkUtilities.cpp b/client/core/utils/networkUtilities.cpp index 96b9131be..e80499ed6 100644 --- a/client/core/utils/networkUtilities.cpp +++ b/client/core/utils/networkUtilities.cpp @@ -42,6 +42,7 @@ #include #endif +#include #include #include @@ -491,3 +492,25 @@ QPair NetworkUtilities::getGatewayAndIface() return { gateway, QNetworkInterface::interfaceFromIndex(index) }; #endif } + +bool NetworkUtilities::hostIsPrivateLanAddress(const QString &host) +{ + if (host.isEmpty()) { + return false; + } + QHostAddress addr(host); + if (addr.isNull() || addr.isLoopback()) { + return false; + } + if (addr.protocol() == QAbstractSocket::IPv4Protocol) { + return addr.isInSubnet(QHostAddress(QStringLiteral("10.0.0.0")), 8) + || addr.isInSubnet(QHostAddress(QStringLiteral("172.16.0.0")), 12) + || addr.isInSubnet(QHostAddress(QStringLiteral("192.168.0.0")), 16) + || addr.isInSubnet(QHostAddress(QStringLiteral("169.254.0.0")), 16); + } + if (addr.protocol() == QAbstractSocket::IPv6Protocol) { + return addr.isInSubnet(QHostAddress(QStringLiteral("fe80::")), 10) + || addr.isInSubnet(QHostAddress(QStringLiteral("fc00::")), 7); + } + return false; +} diff --git a/client/core/utils/networkUtilities.h b/client/core/utils/networkUtilities.h index ebd6aba0e..b972c8dba 100644 --- a/client/core/utils/networkUtilities.h +++ b/client/core/utils/networkUtilities.h @@ -30,6 +30,9 @@ public: static QString netMaskFromIpWithSubnet(const QString ip); static QString ipAddressFromIpWithSubnet(const QString ip); static QStringList summarizeRoutes(const QStringList &ips, const QString cidr); + + /// True for RFC1918 / IPv4 link-local / IPv6 ULA or IPv6 link-local (dev-only LAN gateway with tools/local_gateway). + static bool hostIsPrivateLanAddress(const QString &host); }; #endif // NETWORKUTILITIES_H diff --git a/tools/local_gateway/LAN_GATEWAY.md b/tools/local_gateway/LAN_GATEWAY.md new file mode 100644 index 000000000..598d9bf3a --- /dev/null +++ b/tools/local_gateway/LAN_GATEWAY.md @@ -0,0 +1,77 @@ +# 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://:/`**. +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`** | Replace hardcoded **120s** / **120s** / **0** for pairing and Free mock. | +| **Startup banner** | Logs listen address, chosen **`publicUpdaterBaseURL`**, and **every** `http://: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::isLocalGatewayHost` | Treats LAN like mock for **120s** long-poll alignment with `tools/local_gateway`. | +| `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://: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. diff --git a/tools/local_gateway/README.md b/tools/local_gateway/README.md index 283b9ea6e..3958ef546 100644 --- a/tools/local_gateway/README.md +++ b/tools/local_gateway/README.md @@ -13,16 +13,32 @@ ```bash cd tools/local_gateway go mod download -go run . +go run -buildvcs=false . ``` -Сервер слушает **`0.0.0.0:8080`** (все IPv4‑интерфейсы): с этого Mac — `http://127.0.0.1:8080/`, с телефона в той же LAN — `http://:8080/`. +По умолчанию слушает **`0.0.0.0:8080`** (**`tcp4`** только — см. ниже). С Mac: `http://127.0.0.1:8080/`; с телефона в той же Wi‑Fi: `http://:8080/`. -Сервер поднимается через **`net.Listen("tcp4", "0.0.0.0:8080")`**, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LAN‑IP** (частая нестыковка IPv4/IPv6 у `ListenAndServe(":8080", …)`). +**Флаги и переменные окружения** (полный чеклист: **[LAN_GATEWAY.md](./LAN_GATEWAY.md)**): -После `git pull` обязательно **остановите старый процесс** на 8080 (`Ctrl+C` в терминале или `kill `), иначе будет крутиться бинарник без правок. +| Флаг / 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 (по умолчанию **120s**, как раньше). | +| `-rate-limit-excess-after` | Порог для мока Amnezia Free / CAPTCHA (по умолчанию **0**). | -В логах при старте: `plaintext mock on tcp4 0.0.0.0:8080 — see ... README.md for paths`. Каждый запрос дополнительно пишется как `REQ `. +Пример для 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 `). + +**Логи:** при старте печатается баннер с **`updater_endpoint` URL** и списком **`http://:порт/`** по интерфейсам. Каждый запрос: строки **`REQ start`** (remote addr, path, UA, `X-Client-Request-ID`, размер тела) и **`REQ end`** (HTTP status, длительность). Проверка без клиента (mock должен быть запущен): @@ -45,7 +61,7 @@ bash verify.sh http://127.0.0.1:8080 | `POST` | `/v1/config` | Amnezia Free: CAPTCHA/лимит; иначе короткий мок‑ответ (полноценный premium `vpn://` здесь не строится). | | `POST` | `/v1/news` | Лента новостей (`NewsController`), пустой `news`. | | `POST` | `/v1/renewal_link` | Ссылка продления (`renewal_url`). | -| `POST` | `/v1/updater_endpoint` | `{"url":"http://127.0.0.1:8080"}` → затем GET `/VERSION` на этом хосте. | +| `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 (**120s** mock). | @@ -57,8 +73,9 @@ bash verify.sh http://127.0.0.1:8080 ## Связка с клиентом AmneziaVPN -1. Соберите клиент с определением **`AMNEZIA_LOCAL_GATEWAY`** (см. `client/CMakeLists.txt`, `target_compile_definitions`) — тогда для **`127.0.0.1`** и **`localhost`** запросы к gateway уходят **plaintext JSON** без RSA/AES (см. `GatewayController`, `SecureAppSettingsRepository`). -2. В настройках приложения endpoint gateway: **`http://127.0.0.1:8080/`** (дефолт при `AMNEZIA_LOCAL_GATEWAY` в коде). Допустим и `http://localhost:8080/` — тоже plaintext. +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://:8080/`** (с завершающим `/`, как принято в репозитории). Пошаговый план (включая следующие этапы вроде `/v1/account_info`): **`docs/local-gateway-mock.md`**. diff --git a/tools/local_gateway/main.go b/tools/local_gateway/main.go index 4957cf857..3146f7c31 100644 --- a/tools/local_gateway/main.go +++ b/tools/local_gateway/main.go @@ -1,4 +1,4 @@ -// Plaintext mock for AmneziaVPN client (CMake AMNEZIA_LOCAL_GATEWAY=ON + localhost DEV_AGW_ENDPOINT). +// 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 @@ -6,10 +6,15 @@ import ( "bytes" "encoding/base64" "encoding/json" + "flag" + "fmt" "io" "log" "net" "net/http" + "os" + "sort" + "strings" "sync" "time" @@ -23,19 +28,17 @@ func shortID(id string) string { 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 + // Configured from flags / env in main(). + pairingSessionTTL = 120 * time.Second + longPollWaitLimit = 120 * 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 { @@ -85,11 +88,40 @@ func drainBody(r *http.Request) { _ = r.Body.Close() } -// logReq logs every request (step 5 in docs/local-gateway-mock.md). +// 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) { - log.Printf("REQ %s %s", r.Method, r.URL.Path) - next(w, r) + 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)) } } @@ -153,7 +185,7 @@ func handleGenerateQR(w http.ResponseWriter, r *http.Request) { session := &pairingSession{ QRUUID: req.QRUUID, - ExpiresAt: time.Now().Add(pairingTTL), + ExpiresAt: time.Now().Add(pairingSessionTTL), Done: make(chan struct{}), } @@ -162,7 +194,8 @@ func handleGenerateQR(w http.ResponseWriter, r *http.Request) { sessions[req.QRUUID] = session mu.Unlock() - log.Printf("pairing REGISTERED uuid=%s ttl=%s", shortID(req.QRUUID), pairingTTL) + 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) timer := time.NewTimer(longPollWaitLimit) defer timer.Stop() @@ -247,7 +280,8 @@ func handleScanQR(w http.ResponseWriter, r *http.Request) { close(session.Done) mu.Unlock() - log.Printf("pairing COMPLETED uuid=%s config_len=%d", shortID(req.QRUUID), len(req.Config)) + log.Printf("pairing COMPLETED uuid=%s phone_install=%s config_len=%d proto_count=%d", + shortID(req.QRUUID), shortID(req.InstallationUUID), len(req.Config), len(req.SupportedProto)) writeJSON(w, http.StatusOK, map[string]string{"message": "OK"}) } @@ -457,7 +491,8 @@ func handleUpdaterEndpoint(w http.ResponseWriter, r *http.Request) { return } drainBody(r) - writeJSON(w, http.StatusOK, map[string]string{"url": "http://127.0.0.1:8080"}) + 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. @@ -498,7 +533,148 @@ func handleGetReleaseDate(w http.ResponseWriter, r *http.Request) { 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://:port") + pairTTL := flag.Duration("pairing-ttl", 120*time.Second, "QR pairing session TTL") + longPoll := flag.Duration("long-poll", 120*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)) @@ -513,11 +689,13 @@ func main() { http.HandleFunc("/v1/revoke_native_config", logReq(handleRevokeNoop)) http.HandleFunc("/api/v1/generate_qr", logReq(handleGenerateQR)) http.HandleFunc("/api/v1/scan_qr", logReq(handleScanQR)) - const addr = "0.0.0.0:8080" - log.Printf("plaintext mock on tcp4 %s — see tools/local_gateway/README.md for paths\n", addr) - ln, err := net.Listen("tcp4", addr) + + 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)) } diff --git a/tools/local_gateway/verify.sh b/tools/local_gateway/verify.sh index 1717e674c..f95c83161 100755 --- a/tools/local_gateway/verify.sh +++ b/tools/local_gateway/verify.sh @@ -5,6 +5,8 @@ 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