fixed server go

This commit is contained in:
dranik
2026-05-07 22:30:18 +03:00
parent c877e1e5cb
commit f65fd4a8c5
10 changed files with 365 additions and 32 deletions

View File

@@ -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).

View File

@@ -2,12 +2,14 @@
#include <QJsonDocument>
#include <QSysInfo>
#include <QUrl>
#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)

View File

@@ -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;

View File

@@ -3,6 +3,7 @@
#include <QJsonDocument>
#include <QJsonArray>
#include <QUuid>
#include <QUrl>
#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;

View File

@@ -42,6 +42,7 @@
#include <net/if.h>
#endif
#include <QAbstractSocket>
#include <QHostAddress>
#include <QHostInfo>
@@ -491,3 +492,25 @@ QPair<QString, QNetworkInterface> 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;
}

View File

@@ -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

View File

@@ -0,0 +1,77 @@
# Local gateway: LAN / WiFi (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 WiFi 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`** | Replace hardcoded **120s** / **120s** / **0** for pairing 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::isLocalGatewayHost` | Treats LAN like mock for **120s** long-poll alignment with `tools/local_gateway`. |
| `SecureAppSettingsRepository::getGatewayEndpoint(true)` | Keeps users 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.

View File

@@ -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://<IP-это-машины>:8080/`.
По умолчанию слушает **`0.0.0.0:8080`** (**`tcp4`** только — см. ниже). С Mac: `http://127.0.0.1:8080/`; с телефона в той же WiFi: `http://<LAN-IP-Mac>:8080/`.
Сервер поднимается через **`net.Listen("tcp4", "0.0.0.0:8080")`**, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LANIP** (частая нестыковка IPv4/IPv6 у `ListenAndServe(":8080", …)`).
**Флаги и переменные окружения** (полный чеклист: **[LAN_GATEWAY.md](./LAN_GATEWAY.md)**):
После `git pull` обязательно **остановите старый процесс** на 8080 (`Ctrl+C` в терминале или `kill <PID>`), иначе будет крутиться бинарник без правок.
| Флаг / 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 <METHOD> <path>`.
Пример для iPhone + Mac:
```bash
LOCAL_GATEWAY_PUBLIC_BASE='http://192.168.1.10:8080' go run -buildvcs=false .
```
`net.Listen("tcp4", …)` оставлен, чтобы на macOS не ловить пустой ответ при `curl`/браузере на **LANIPv4** (частая нестыковка 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 должен быть запущен):
@@ -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://<LAN-IP>:8080/`** (с завершающим `/`, как принято в репозитории).
Пошаговый план (включая следующие этапы вроде `/v1/account_info`): **`docs/local-gateway-mock.md`**.

View File

@@ -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://<first-lan-ipv4>: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))
}

View File

@@ -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