Enhance API routing and testing for user endpoints
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m52s

- 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.
This commit is contained in:
Denozordec
2026-03-30 11:18:36 +07:00
parent d69849e5e2
commit 368473de59
4 changed files with 102 additions and 9 deletions
+1 -1
View File
@@ -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 прокси, а не клиента.
+8 -1
View File
@@ -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
+75
View File
@@ -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 {
+18 -7
View File
@@ -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}
<span title="Нет ответа stats/summary для этой ноды"></span>
{:else if 'ok' in st && st.ok === false}
<span class="text-xs text-muted-foreground"></span>
<span class="text-xs text-muted-foreground" title={st.error}>—</span>
{:else if st && 'bad' in st && st.ok}
{st.bad ?? '—'}
{st.bad ?? 0}
{:else}
<span title="Неожиданное состояние stats"></span>
{/if}
{:else}