Enhance API path normalization and configuration validation
- Introduced a new function to normalize request URL paths, collapsing duplicate slashes and clearing raw paths to ensure correct routing in the reverse proxy. - Updated the gateway to utilize the normalization function for API requests, improving routing consistency. - Trimmed whitespace from server configuration fields in the validation process to prevent potential issues with malformed URLs. - Enhanced documentation in GATEWAY_RUN.md to clarify the importance of proper URL formatting and configuration.
This commit is contained in:
@@ -186,6 +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` и сравните с запросом через шлюз.
|
||||||
|
- **`400` на `/api/{alias}/health` при `base_url: http://172.20.x.x:9091`**: убедитесь, что в YAML **нет пробела или переноса строки** после URL — иначе в исходящий `Host` может попасть `\r`/пробел, и строгий HTTP‑стек upstream отвечает `400`. Поля `base_url`, `alias`, `path_prefix` при загрузке конфига **обрезаются по краям** (`TrimSpace`). Проверьте также URL в браузере без **двойного слэша** (`/api//mtg/…`): шлюз теперь нормализует путь под `/api`.
|
||||||
- **Список пользователей через шлюз**: запрос **`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).
|
- **Список пользователей через шлюз**: запрос **`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`.
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func (c *Config) Validate() error {
|
|||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for i := range c.Servers {
|
for i := range c.Servers {
|
||||||
s := &c.Servers[i]
|
s := &c.Servers[i]
|
||||||
|
s.Alias = strings.TrimSpace(s.Alias)
|
||||||
|
s.BaseURL = strings.TrimSpace(s.BaseURL)
|
||||||
|
s.PathPrefix = strings.TrimSpace(s.PathPrefix)
|
||||||
|
s.AuthorizationEnv = strings.TrimSpace(s.AuthorizationEnv)
|
||||||
if s.Alias == "" {
|
if s.Alias == "" {
|
||||||
return fmt.Errorf("servers[%d]: alias is required", i)
|
return fmt.Errorf("servers[%d]: alias is required", i)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,32 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NormalizeRequestURLPath collapses duplicate slashes and dot-segments in req.URL.Path
|
||||||
|
// (path.Clean) and clears RawPath so strip-prefix routing matches the client path even
|
||||||
|
// when the request line contained "//" (e.g. /api//mtg/health). Without this, the path
|
||||||
|
// may not match /api/{alias} and SingleHostReverseProxy forwards a wrong path upstream.
|
||||||
|
func NormalizeRequestURLPath(req *http.Request) {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := req.URL
|
||||||
|
if u.Path == "" {
|
||||||
|
u.Path = "/"
|
||||||
|
u.RawPath = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := path.Clean(u.Path)
|
||||||
|
if !strings.HasPrefix(c, "/") {
|
||||||
|
c = "/" + c
|
||||||
|
}
|
||||||
|
u.Path = c
|
||||||
|
u.RawPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
// 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 + health → https://host/v1/health).
|
// (e.g. https://host/api/ + v1 + health → https://host/v1/health).
|
||||||
@@ -20,6 +43,7 @@ func NewReverseProxy(target *url.URL, stripPrefix, pathPrefix string, setAuth st
|
|||||||
orig := proxy.Director
|
orig := proxy.Director
|
||||||
targetQuery := target.RawQuery
|
targetQuery := target.RawQuery
|
||||||
proxy.Director = func(req *http.Request) {
|
proxy.Director = func(req *http.Request) {
|
||||||
|
NormalizeRequestURLPath(req)
|
||||||
p := req.URL.Path
|
p := req.URL.Path
|
||||||
if !strings.HasPrefix(p, stripPrefix) {
|
if !strings.HasPrefix(p, stripPrefix) {
|
||||||
orig(req)
|
orig(req)
|
||||||
|
|||||||
@@ -25,6 +25,32 @@ func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDirectorDoubleSlashPathMatchesStripPrefix(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/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.URL.Path = "/api//mtg/health"
|
||||||
|
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/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertSameURL(t, cap.got.URL, want)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDirectorRewritesPath(t *testing.T) {
|
func TestDirectorRewritesPath(t *testing.T) {
|
||||||
target, err := url.Parse("http://127.0.0.1:9")
|
target, err := url.Parse("http://127.0.0.1:9")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.promHandler.ServeHTTP(w, r)
|
g.promHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// So /api//mtg/health and /api/../api/agg/... route like /api/mtg/health and /api/agg/...
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||||
|
proxy.NormalizeRequestURLPath(r)
|
||||||
|
}
|
||||||
const prefix = "/api/"
|
const prefix = "/api/"
|
||||||
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
|
if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") {
|
||||||
g.agg.ServeHTTP(w, r)
|
g.agg.ServeHTTP(w, r)
|
||||||
|
|||||||
Reference in New Issue
Block a user