From 368473de59793aa10bfcae626f3e5455454d8e81 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 30 Mar 2026 11:18:36 +0700 Subject: [PATCH] Enhance API routing and testing for user endpoints - Updated the reverse proxy to redirect `GET` requests for `/api/{alias}/users` to `/v1/stats/users`, ensuring compatibility with Telemt builds that handle these requests differently. - Added tests to verify that `GET` and `HEAD` requests to `/api/{alias}/users` are correctly rewritten, while `POST` requests and user-specific retrievals remain unchanged. - Improved documentation in GATEWAY_RUN.md to clarify the behavior of the API routing and the importance of using the correct base URL. --- docs/GATEWAY_RUN.md | 2 +- internal/proxy/reverse.go | 9 +++- internal/proxy/reverse_test.go | 75 ++++++++++++++++++++++++++++++++++ web/src/routes/+page.svelte | 25 ++++++++---- 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 803296a..8417e88 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -186,7 +186,7 @@ docker compose down - **Nginx с `location /api/` и `proxy_pass http://…:9091/;` (со слэшем в конце)** на бэкенд уходит путь **без** префикса `/api/` (например запрос к nginx `GET /api/v1/users` превращается в `GET /v1/users` на Telemt). Шлюз при `base_url: https://gt2.example/api/` должен запрашивать именно **`/api/v1/…`** на стороне nginx. Если в `base_url` нет пути `/api/` (только `https://gt2.example`), шлюз обратится к `https://gt2.example/v1/…` — часто это **не** попадает в `location /api/`, и nginx отдаёт **чужой vhost / заглушку**. Задавайте `base_url` с завершающим слэшем: `https://gt2.example/api/`. - **Заголовок `Host`**: шлюз выставляет `Host` равным хосту из `base_url` (как у обычного клиента к этому имени). Если после обновления образа проблема остаётся, с хоста шлюза проверьте: `curl -sv -o /dev/null https://gt2…/api/v1/health` и сравните с запросом через шлюз. -- **`/v1/users` и `/v1/stats/users`** — разные маршруты в Telemt Control API. То, что открывается как `…/api/v1/stats/users` в браузере, через шлюз соответствует **`/api/{alias}/stats/users`**, а не `/api/{alias}/users`. См. матрицу путей в [API.md](API.md). +- **Список пользователей через шлюз**: запрос **`GET` или `HEAD`** на **`/api/{alias}/users`** шлюз перенаправляет на upstream **`GET/HEAD /v1/stats/users`** (как и агрегатор). Так совместимы сборки Telemt, где прямой **`GET /v1/users`** даёт ошибку (например `400`), а **`/v1/stats/users`** работает. **`POST /api/{alias}/users`** (создание) и **`GET /api/{alias}/users/{username}`** по-прежнему идут на **`/v1/users`** и **`/v1/users/{username}`**. Явный путь **`/api/{alias}/stats/users`** не меняется. См. [API.md](API.md). - **`docker pull`: `unauthorized` / `denied`**: выполните `docker login git.shts.su` с учётной записью Gitea и PAT с **`read:package`**. - **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`. - **За reverse proxy**: укажите CIDR прокси в `trusted_proxies`, иначе whitelist видит IP прокси, а не клиента. diff --git a/internal/proxy/reverse.go b/internal/proxy/reverse.go index 4c02553..65ceffb 100644 --- a/internal/proxy/reverse.go +++ b/internal/proxy/reverse.go @@ -9,7 +9,11 @@ import ( // NewReverseProxy builds a reverse proxy to target base URL with path rewriting: // stripPrefix (/api/{alias}) + pathPrefix (/v1) + remainder, joined onto target via url.JoinPath -// (e.g. https://host/api/ + v1 + users → https://host/api/v1/users). +// (e.g. https://host/api/ + v1 + health → https://host/v1/health). +// +// Compatibility: GET/HEAD .../api/{alias}/users (list only, no extra path segment) is sent upstream as +// /v1/stats/users. Some Telemt builds treat GET /v1/users incorrectly (e.g. 400) while /v1/stats/users works. +// POST .../users (create) and GET .../users/{username} are unchanged. func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth string) *httputil.ReverseProxy { proxy := httputil.NewSingleHostReverseProxy(target) proxy.Transport = DirectTransport() @@ -30,6 +34,9 @@ func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth st } rest := strings.TrimPrefix(p, stripPrefix) rest = strings.TrimPrefix(rest, "/") + if (req.Method == http.MethodGet || req.Method == http.MethodHead) && rest == "users" { + rest = "stats/users" + } joined := buildUpstreamURL(target, pathPrefix, rest) req.URL.Scheme = joined.Scheme req.URL.Host = joined.Host diff --git a/internal/proxy/reverse_test.go b/internal/proxy/reverse_test.go index f8eb33a..abab8ed 100644 --- a/internal/proxy/reverse_test.go +++ b/internal/proxy/reverse_test.go @@ -75,6 +75,81 @@ func TestDirectorRewritesPathWithAPIBasePath(t *testing.T) { assertSameURL(t, cap.got.URL, want) } +func TestDirectorGETUsersListUsesStatsUsers(t *testing.T) { + target, err := url.Parse("http://127.0.0.1:9") + if err != nil { + t.Fatal(err) + } + cap := &captureTransport{} + rp := NewReverseProxy(target, "/api/mtg", "/v1", "") + rp.Transport = cap + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/mtg/users", nil) + if err != nil { + t.Fatal(err) + } + rp.ServeHTTP(httptest.NewRecorder(), req) + + if cap.got == nil { + t.Fatal("no outgoing request captured") + } + want, err := url.Parse("http://127.0.0.1:9/v1/stats/users") + if err != nil { + t.Fatal(err) + } + assertSameURL(t, cap.got.URL, want) +} + +func TestDirectorPOSTUsersCreateNotRewritten(t *testing.T) { + target, err := url.Parse("http://127.0.0.1:9") + if err != nil { + t.Fatal(err) + } + cap := &captureTransport{} + rp := NewReverseProxy(target, "/api/mtg", "/v1", "") + rp.Transport = cap + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://127.0.0.1:9/api/mtg/users", nil) + if err != nil { + t.Fatal(err) + } + rp.ServeHTTP(httptest.NewRecorder(), req) + + if cap.got == nil { + t.Fatal("no outgoing request captured") + } + want, err := url.Parse("http://127.0.0.1:9/v1/users") + if err != nil { + t.Fatal(err) + } + assertSameURL(t, cap.got.URL, want) +} + +func TestDirectorGETUsersByNameNotRewritten(t *testing.T) { + target, err := url.Parse("http://127.0.0.1:9") + if err != nil { + t.Fatal(err) + } + cap := &captureTransport{} + rp := NewReverseProxy(target, "/api/mtg", "/v1", "") + rp.Transport = cap + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/mtg/users/alice", nil) + if err != nil { + t.Fatal(err) + } + rp.ServeHTTP(httptest.NewRecorder(), req) + + if cap.got == nil { + t.Fatal("no outgoing request captured") + } + want, err := url.Parse("http://127.0.0.1:9/v1/users/alice") + if err != nil { + t.Fatal(err) + } + assertSameURL(t, cap.got.URL, want) +} + func TestDirectorRewritesNestedStatsUsers(t *testing.T) { target, err := url.Parse("http://127.0.0.1:9/api/") if err != nil { diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a090089..c2247cc 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -47,8 +47,9 @@ fleet = f.data ?? null; uniqueIps = u.data ?? null; + // Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально). const aliases = (fleet?.servers ?? []) - .filter((x) => x.alias && x.ok && x.health_ok && x.system_info_ok) + .filter((x) => x.alias && x.health_ok && x.system_info_ok) .map((x) => x.alias as string); const next: typeof nodeStats = {}; const concurrency = 4; @@ -61,8 +62,8 @@ const d = st.data; next[alias] = { ok: true, - bad: d.connections_bad_total, - total: d.connections_total + bad: numStat(d.connections_bad_total, 0), + total: numStat(d.connections_total, 0) }; } catch (e) { next[alias] = { @@ -83,6 +84,16 @@ onMount(load); + /** Число из ответа Telemt (поле может отсутствовать или прийти строкой). */ + function numStat(v: unknown, fallback = 0): number { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return fallback; + } + let totalBad = $derived.by(() => { let n = 0; for (const v of Object.values(nodeStats)) { @@ -268,13 +279,13 @@ {#if srv.alias} {@const st = nodeStats[srv.alias]} {#if !st} - — + {:else if 'ok' in st && st.ok === false} - + {:else if st && 'bad' in st && st.ok} - {st.bad ?? '—'} + {st.bad ?? 0} {:else} - — + {/if} {:else} —