Refactor reverse proxy and enhance API routing
- Replaced the existing reverse proxy implementation with a new alias forwarding mechanism, improving path handling and request normalization. - Updated the gateway to utilize the new forwarding approach, ensuring consistent handling of API requests and proper error management. - Enhanced tests to validate the new routing behavior, including handling of double slashes and user endpoint requests. - Improved documentation in GATEWAY_RUN.md to clarify the updated API routing and configuration requirements.
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` и сравните с запросом через шлюз.
|
||||
- **`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`.
|
||||
- **`400` на `/api/{alias}/…` при локальном `base_url` (например `http://172.20.0.3:9091`), хотя `curl` к `:9091/v1/…` даёт `200`**: частая причина — **несовпадение заголовка `Host`**: браузер шлёт `Host: публичное_имя:8888`, а при прямом `curl` к IP в `Host` попадает `172.20.0.3:9091`. Строгий upstream (часто hyper/Rust) отвечает `400`, если `Host` не совпадает с ожидаемым authority. Шлюз при проксировании **не пересылает** клиентский `Host` и выставляет authority из `base_url` (как серверные запросы агрегатора). Убедитесь также, что в YAML **нет пробела/переноса** в конце `base_url` (поля обрезаются `TrimSpace`), и в URL нет лишнего `/api//…` (путь под `/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`.
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/telemt/telemt-api/internal/config"
|
||||
"github.com/telemt/telemt-api/internal/proxy"
|
||||
)
|
||||
|
||||
const statsUsersPath = "stats/users"
|
||||
@@ -42,7 +42,7 @@ func FetchTelemtGET[T any](ctx context.Context, client *http.Client, parsed *con
|
||||
if err != nil {
|
||||
return zero, UpstreamCallMeta{OK: false, Error: err.Error()}
|
||||
}
|
||||
target := joinPathPrefix(u, srv.PathPrefix, relPath)
|
||||
target := proxy.JoinPathPrefix(u, srv.PathPrefix, relPath)
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
@@ -102,28 +102,6 @@ func FetchStatsUsers(ctx context.Context, client *http.Client, parsed *config.Pa
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPathPrefix(base *url.URL, pathPrefix, rest string) *url.URL {
|
||||
rel := strings.Trim(pathPrefix, "/")
|
||||
if rest != "" {
|
||||
if rel != "" {
|
||||
rel = rel + "/" + rest
|
||||
} else {
|
||||
rel = rest
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg != "" {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
out := *base
|
||||
return &out
|
||||
}
|
||||
return base.JoinPath(parts...)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewAliasForward proxies /api/{alias}/… to Telemt using http.NewRequest(fullURL)+RoundTrip,
|
||||
// matching aggregate server-side calls. httputil.ReverseProxy can produce request lines that
|
||||
// strict origin servers reject with 400; this path matches a working curl to base_url.
|
||||
func NewAliasForward(
|
||||
target *url.URL,
|
||||
stripPrefix, pathPrefix, auth string,
|
||||
rt http.RoundTripper,
|
||||
errHandler func(http.ResponseWriter, *http.Request, error),
|
||||
) http.Handler {
|
||||
if errHandler == nil {
|
||||
errHandler = defaultForwardErrorHandler
|
||||
}
|
||||
if rt == nil {
|
||||
rt = http.DefaultTransport
|
||||
}
|
||||
targetQuery := target.RawQuery
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NormalizeRequestURLPath(r)
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, stripPrefix) {
|
||||
errHandler(w, r, fmt.Errorf("path %q: missing strip prefix %q", p, stripPrefix))
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(p, stripPrefix)
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && rest == "users" {
|
||||
rest = "stats/users"
|
||||
}
|
||||
|
||||
outURL := JoinPathPrefix(target, pathPrefix, rest)
|
||||
u := *outURL
|
||||
if targetQuery == "" || r.URL.RawQuery == "" {
|
||||
u.RawQuery = targetQuery + r.URL.RawQuery
|
||||
} else {
|
||||
u.RawQuery = targetQuery + "&" + r.URL.RawQuery
|
||||
}
|
||||
outURL = &u
|
||||
|
||||
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, outURL.String(), r.Body)
|
||||
if err != nil {
|
||||
errHandler(w, r, err)
|
||||
return
|
||||
}
|
||||
if r.ContentLength >= 0 {
|
||||
outReq.ContentLength = r.ContentLength
|
||||
}
|
||||
outReq.Header = cloneHeader(r.Header)
|
||||
removeConnectionHeaders(outReq.Header)
|
||||
// Do not forward the client's Host (e.g. mtg.ivx.su:8888). Upstream must see the
|
||||
// authority from base_url (e.g. 172.20.0.3:9091); mismatch often yields 400 from strict stacks.
|
||||
outReq.Header.Del("Host")
|
||||
outReq.Host = outURL.Host
|
||||
if auth != "" {
|
||||
outReq.Header.Set("Authorization", auth)
|
||||
}
|
||||
|
||||
resp, err := rt.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
errHandler(w, r, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
removeConnectionHeaders(resp.Header)
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
})
|
||||
}
|
||||
|
||||
func defaultForwardErrorHandler(w http.ResponseWriter, _ *http.Request, _ error) {
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func cloneHeader(h http.Header) http.Header {
|
||||
h2 := make(http.Header, len(h))
|
||||
for k, vv := range h {
|
||||
cp := make([]string, len(vv))
|
||||
copy(cp, vv)
|
||||
h2[k] = cp
|
||||
}
|
||||
return h2
|
||||
}
|
||||
|
||||
// removeConnectionHeaders mirrors net/http/httputil.ReverseProxy hop-by-hop handling.
|
||||
func removeConnectionHeaders(h http.Header) {
|
||||
if v := h.Get("Connection"); v != "" {
|
||||
for _, f := range strings.Split(v, ",") {
|
||||
if f = textproto.TrimString(f); f != "" {
|
||||
h.Del(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range hopHeaders {
|
||||
h.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
var hopHeaders = []string{
|
||||
"Connection",
|
||||
"Proxy-Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Te",
|
||||
"Trailer",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JoinPathPrefix builds the upstream URL the same way as aggregate FetchTelemtGET:
|
||||
// base URL + path_prefix (e.g. /v1) + rest (e.g. health, stats/users) via url.JoinPath.
|
||||
func JoinPathPrefix(base *url.URL, pathPrefix, rest string) *url.URL {
|
||||
rel := strings.Trim(pathPrefix, "/")
|
||||
if rest != "" {
|
||||
if rel != "" {
|
||||
rel = rel + "/" + rest
|
||||
} else {
|
||||
rel = rest
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg != "" {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
out := *base
|
||||
return &out
|
||||
}
|
||||
return base.JoinPath(parts...)
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
@@ -11,7 +9,7 @@ import (
|
||||
// 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.
|
||||
// may not match /api/{alias} and alias routing fails.
|
||||
func NormalizeRequestURLPath(req *http.Request) {
|
||||
if req == nil || req.URL == nil {
|
||||
return
|
||||
@@ -29,78 +27,3 @@ func NormalizeRequestURLPath(req *http.Request) {
|
||||
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).
|
||||
//
|
||||
// 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()
|
||||
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)
|
||||
if setAuth != "" {
|
||||
req.Header.Set("Authorization", setAuth)
|
||||
}
|
||||
// Server-side requests carry RequestURI; client RoundTrip rejects it with URL.Host set.
|
||||
req.RequestURI = ""
|
||||
req.Header.Del("Host")
|
||||
req.Host = req.URL.Host
|
||||
return
|
||||
}
|
||||
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
|
||||
req.URL.Path = joined.Path
|
||||
req.URL.RawPath = joined.RawPath
|
||||
req.URL.Opaque = ""
|
||||
// Match Host header to authority; clear stale map entry (e.g. from httptest.NewRequest).
|
||||
req.Header.Del("Host")
|
||||
req.Host = req.URL.Host
|
||||
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||
} else {
|
||||
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||
}
|
||||
if setAuth != "" {
|
||||
req.Header.Set("Authorization", setAuth)
|
||||
}
|
||||
req.RequestURI = ""
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func buildUpstreamURL(target *url.URL, pathPrefix, rest string) *url.URL {
|
||||
rel := strings.Trim(pathPrefix, "/")
|
||||
if rest != "" {
|
||||
if rel != "" {
|
||||
rel = rel + "/" + rest
|
||||
} else {
|
||||
rel = rest
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg != "" {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
out := *target
|
||||
return &out
|
||||
}
|
||||
return target.JoinPath(parts...)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ import (
|
||||
)
|
||||
|
||||
// captureTransport records the outgoing request and returns 200 without dialing.
|
||||
// httptest.ResponseRecorder + ReverseProxy + real httptest.Server can yield flaky
|
||||
// or environment-dependent failures (proxy env, request-line quirks); we assert
|
||||
// Director output directly instead.
|
||||
type captureTransport struct {
|
||||
got *http.Request
|
||||
}
|
||||
@@ -25,21 +22,20 @@ func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestDirectorDoubleSlashPathMatchesStripPrefix(t *testing.T) {
|
||||
func TestForwardDoubleSlashPathMatchesStripPrefix(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
|
||||
h := NewAliasForward(target, "/api/mtg", "/v1", "", cap, nil)
|
||||
|
||||
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)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -51,20 +47,19 @@ func TestDirectorDoubleSlashPathMatchesStripPrefix(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorRewritesPath(t *testing.T) {
|
||||
func TestForwardRewritesPath(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/main_srv", "/v1", "")
|
||||
rp.Transport = cap
|
||||
h := NewAliasForward(target, "/api/main_srv", "/v1", "", cap, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/main_srv/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rp.ServeHTTP(httptest.NewRecorder(), req)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -76,20 +71,19 @@ func TestDirectorRewritesPath(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorRewritesPathWithAPIBasePath(t *testing.T) {
|
||||
func TestForwardRewritesPathWithAPIBasePath(t *testing.T) {
|
||||
target, err := url.Parse("http://127.0.0.1:9/api/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := &captureTransport{}
|
||||
rp := NewReverseProxy(target, "/api/main_srv", "/v1", "")
|
||||
rp.Transport = cap
|
||||
h := NewAliasForward(target, "/api/main_srv", "/v1", "", cap, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/main_srv/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rp.ServeHTTP(httptest.NewRecorder(), req)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -101,20 +95,19 @@ func TestDirectorRewritesPathWithAPIBasePath(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorGETUsersListUsesStatsUsers(t *testing.T) {
|
||||
func TestForwardGETUsersListUsesStatsUsers(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
|
||||
h := NewAliasForward(target, "/api/mtg", "/v1", "", cap, nil)
|
||||
|
||||
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)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -126,20 +119,19 @@ func TestDirectorGETUsersListUsesStatsUsers(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorPOSTUsersCreateNotRewritten(t *testing.T) {
|
||||
func TestForwardPOSTUsersCreateNotRewritten(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
|
||||
h := NewAliasForward(target, "/api/mtg", "/v1", "", cap, nil)
|
||||
|
||||
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)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -151,20 +143,19 @@ func TestDirectorPOSTUsersCreateNotRewritten(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorGETUsersByNameNotRewritten(t *testing.T) {
|
||||
func TestForwardGETUsersByNameNotRewritten(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
|
||||
h := NewAliasForward(target, "/api/mtg", "/v1", "", cap, nil)
|
||||
|
||||
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)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -176,20 +167,19 @@ func TestDirectorGETUsersByNameNotRewritten(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
func TestDirectorRewritesNestedStatsUsers(t *testing.T) {
|
||||
func TestForwardNestedStatsUsers(t *testing.T) {
|
||||
target, err := url.Parse("http://127.0.0.1:9/api/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := &captureTransport{}
|
||||
rp := NewReverseProxy(target, "/api/gt2", "/v1", "")
|
||||
rp.Transport = cap
|
||||
h := NewAliasForward(target, "/api/gt2", "/v1", "", cap, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:9/api/gt2/stats/users", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rp.ServeHTTP(httptest.NewRecorder(), req)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
@@ -201,9 +191,32 @@ func TestDirectorRewritesNestedStatsUsers(t *testing.T) {
|
||||
assertSameURL(t, cap.got.URL, want)
|
||||
}
|
||||
|
||||
// TestBuildUpstreamURLRoundTrip checks that a URL built like the Director does is
|
||||
// accepted by net/http against httptest (no ReverseProxy). Isolates JoinPath + server.
|
||||
func TestBuildUpstreamURLRoundTrip(t *testing.T) {
|
||||
func TestForwardOutgoingHostIsUpstreamAuthority(t *testing.T) {
|
||||
target, err := url.Parse("http://172.20.0.3:9091")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cap := &captureTransport{}
|
||||
h := NewAliasForward(target, "/api/mtg", "/v1", "", cap, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://public.example/api/mtg/health", nil)
|
||||
req.Host = "public.example:8888"
|
||||
req.Header.Set("Host", "public.example:8888")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
if cap.got == nil {
|
||||
t.Fatal("no outgoing request captured")
|
||||
}
|
||||
if cap.got.URL.Host != "172.20.0.3:9091" {
|
||||
t.Fatalf("URL.Host=%q", cap.got.URL.Host)
|
||||
}
|
||||
if cap.got.Host != "172.20.0.3:9091" {
|
||||
t.Fatalf("Request.Host=%q want 172.20.0.3:9091", cap.got.Host)
|
||||
}
|
||||
if h := cap.got.Header.Get("Host"); h != "" {
|
||||
t.Fatalf("Header Host should be empty (use Request.Host); got %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinPathPrefixRoundTrip(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/health" {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
@@ -217,7 +230,7 @@ func TestBuildUpstreamURLRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := buildUpstreamURL(base, "/v1", "health")
|
||||
joined := JoinPathPrefix(base, "/v1", "health")
|
||||
req, err := http.NewRequest(http.MethodGet, joined.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -237,7 +250,6 @@ func assertSameURL(t *testing.T, got, want *url.URL) {
|
||||
if got == nil || want == nil {
|
||||
t.Fatalf("nil URL: got=%v want=%v", got, want)
|
||||
}
|
||||
// JoinPath vs url.Parse can differ in Path vs RawPath while String() is identical.
|
||||
if got.String() == want.String() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,7 +22,7 @@ import (
|
||||
// Gateway serves health, metrics, and proxied API routes.
|
||||
type Gateway struct {
|
||||
parsed *config.Parsed
|
||||
proxies map[string]*httputil.ReverseProxy
|
||||
proxies map[string]http.Handler
|
||||
agg *aggregate.Handler
|
||||
geo *geoip.Service
|
||||
log *slog.Logger
|
||||
@@ -44,7 +43,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
t.ResponseHeaderTimeout = 120 * time.Second
|
||||
g := &Gateway{
|
||||
parsed: p,
|
||||
proxies: make(map[string]*httputil.ReverseProxy),
|
||||
proxies: make(map[string]http.Handler),
|
||||
geo: geo,
|
||||
log: log,
|
||||
transport: t,
|
||||
@@ -58,9 +57,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
}
|
||||
auth := p.AuthByAlias[s.Alias]
|
||||
strip := "/api/" + s.Alias
|
||||
rp := proxy.NewReverseProxy(u, strip, s.PathPrefix, auth)
|
||||
rp.Transport = t
|
||||
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
g.proxies[s.Alias] = proxy.NewAliasForward(u, strip, s.PathPrefix, auth, t, func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Error("upstream error", "alias", s.Alias, "err", err)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
@@ -68,8 +65,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
|
||||
"ok": false,
|
||||
"error": map[string]string{"code": "bad_gateway", "message": "upstream unreachable"},
|
||||
})
|
||||
}
|
||||
g.proxies[s.Alias] = rp
|
||||
})
|
||||
}
|
||||
var aggCacheTTL time.Duration
|
||||
if p.Config.Aggregate != nil && p.Config.Aggregate.CacheTTLMs > 0 {
|
||||
|
||||
Reference in New Issue
Block a user