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/`.
|
||||
- **Заголовок `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 прокси, а не клиента.
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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}
|
||||
—
|
||||
|
||||
Reference in New Issue
Block a user