diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 8417e88..e09c9be 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -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/`. - **Заголовок `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). - **`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`. diff --git a/internal/config/config.go b/internal/config/config.go index f221232..b5db4b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,6 +75,10 @@ func (c *Config) Validate() error { seen := make(map[string]struct{}) for i := range c.Servers { 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 == "" { return fmt.Errorf("servers[%d]: alias is required", i) } diff --git a/internal/proxy/reverse.go b/internal/proxy/reverse.go index 65ceffb..7a953a8 100644 --- a/internal/proxy/reverse.go +++ b/internal/proxy/reverse.go @@ -4,9 +4,32 @@ import ( "net/http" "net/http/httputil" "net/url" + "path" "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: // stripPrefix (/api/{alias}) + pathPrefix (/v1) + remainder, joined onto target via url.JoinPath // (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 targetQuery := target.RawQuery proxy.Director = func(req *http.Request) { + NormalizeRequestURLPath(req) p := req.URL.Path if !strings.HasPrefix(p, stripPrefix) { orig(req) diff --git a/internal/proxy/reverse_test.go b/internal/proxy/reverse_test.go index abab8ed..b8a4a16 100644 --- a/internal/proxy/reverse_test.go +++ b/internal/proxy/reverse_test.go @@ -25,6 +25,32 @@ func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) }, 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) { target, err := url.Parse("http://127.0.0.1:9") if err != nil { diff --git a/internal/server/gateway.go b/internal/server/gateway.go index c4ea028..e56dd78 100644 --- a/internal/server/gateway.go +++ b/internal/server/gateway.go @@ -228,6 +228,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) { g.promHandler.ServeHTTP(w, r) 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/" if r.URL.Path == "/api/agg" || strings.HasPrefix(r.URL.Path, "/api/agg/") { g.agg.ServeHTTP(w, r)