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.
This commit is contained in:
+1
-1
@@ -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/`.
|
- **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` и сравните с запросом через шлюз.
|
- **Заголовок `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`**.
|
- **`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`.
|
- **`403 forbidden` с хоста при `allow_all: false`**: добавьте CIDR клиента в `whitelist_cidrs`. Запросы из контейнера к самому себе идут с `127.0.0.1` — при необходимости добавьте `127.0.0.1/32`.
|
||||||
- **За reverse proxy**: укажите CIDR прокси в `trusted_proxies`, иначе whitelist видит IP прокси, а не клиента.
|
- **За reverse proxy**: укажите CIDR прокси в `trusted_proxies`, иначе whitelist видит IP прокси, а не клиента.
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import (
|
|||||||
|
|
||||||
// NewReverseProxy builds a reverse proxy to target base URL with path rewriting:
|
// NewReverseProxy builds a reverse proxy to target base URL with path rewriting:
|
||||||
// stripPrefix (/api/{alias}) + pathPrefix (/v1) + remainder, joined onto target via url.JoinPath
|
// 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 {
|
func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth string) *httputil.ReverseProxy {
|
||||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
proxy.Transport = DirectTransport()
|
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(p, stripPrefix)
|
||||||
rest = strings.TrimPrefix(rest, "/")
|
rest = strings.TrimPrefix(rest, "/")
|
||||||
|
if (req.Method == http.MethodGet || req.Method == http.MethodHead) && rest == "users" {
|
||||||
|
rest = "stats/users"
|
||||||
|
}
|
||||||
joined := buildUpstreamURL(target, pathPrefix, rest)
|
joined := buildUpstreamURL(target, pathPrefix, rest)
|
||||||
req.URL.Scheme = joined.Scheme
|
req.URL.Scheme = joined.Scheme
|
||||||
req.URL.Host = joined.Host
|
req.URL.Host = joined.Host
|
||||||
|
|||||||
@@ -75,6 +75,81 @@ func TestDirectorRewritesPathWithAPIBasePath(t *testing.T) {
|
|||||||
assertSameURL(t, cap.got.URL, want)
|
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) {
|
func TestDirectorRewritesNestedStatsUsers(t *testing.T) {
|
||||||
target, err := url.Parse("http://127.0.0.1:9/api/")
|
target, err := url.Parse("http://127.0.0.1:9/api/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -47,8 +47,9 @@
|
|||||||
fleet = f.data ?? null;
|
fleet = f.data ?? null;
|
||||||
uniqueIps = u.data ?? null;
|
uniqueIps = u.data ?? null;
|
||||||
|
|
||||||
|
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
||||||
const aliases = (fleet?.servers ?? [])
|
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);
|
.map((x) => x.alias as string);
|
||||||
const next: typeof nodeStats = {};
|
const next: typeof nodeStats = {};
|
||||||
const concurrency = 4;
|
const concurrency = 4;
|
||||||
@@ -61,8 +62,8 @@
|
|||||||
const d = st.data;
|
const d = st.data;
|
||||||
next[alias] = {
|
next[alias] = {
|
||||||
ok: true,
|
ok: true,
|
||||||
bad: d.connections_bad_total,
|
bad: numStat(d.connections_bad_total, 0),
|
||||||
total: d.connections_total
|
total: numStat(d.connections_total, 0)
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next[alias] = {
|
next[alias] = {
|
||||||
@@ -83,6 +84,16 @@
|
|||||||
|
|
||||||
onMount(load);
|
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 totalBad = $derived.by(() => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const v of Object.values(nodeStats)) {
|
for (const v of Object.values(nodeStats)) {
|
||||||
@@ -268,13 +279,13 @@
|
|||||||
{#if srv.alias}
|
{#if srv.alias}
|
||||||
{@const st = nodeStats[srv.alias]}
|
{@const st = nodeStats[srv.alias]}
|
||||||
{#if !st}
|
{#if !st}
|
||||||
—
|
<span title="Нет ответа stats/summary для этой ноды">—</span>
|
||||||
{:else if 'ok' in st && st.ok === false}
|
{: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}
|
{:else if st && 'bad' in st && st.ok}
|
||||||
{st.bad ?? '—'}
|
{st.bad ?? 0}
|
||||||
{:else}
|
{:else}
|
||||||
—
|
<span title="Неожиданное состояние stats">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
—
|
—
|
||||||
|
|||||||
Reference in New Issue
Block a user