diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index e09c9be..aaaac77 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -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`. diff --git a/internal/aggregate/fetch.go b/internal/aggregate/fetch.go index 22a1675..5507c45 100644 --- a/internal/aggregate/fetch.go +++ b/internal/aggregate/fetch.go @@ -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 diff --git a/internal/proxy/forward.go b/internal/proxy/forward.go new file mode 100644 index 0000000..b181e5c --- /dev/null +++ b/internal/proxy/forward.go @@ -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", +} diff --git a/internal/proxy/join.go b/internal/proxy/join.go new file mode 100644 index 0000000..f3577fa --- /dev/null +++ b/internal/proxy/join.go @@ -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...) +} diff --git a/internal/proxy/reverse.go b/internal/proxy/reverse.go index 7a953a8..5773e17 100644 --- a/internal/proxy/reverse.go +++ b/internal/proxy/reverse.go @@ -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...) -} diff --git a/internal/proxy/reverse_test.go b/internal/proxy/reverse_test.go index b8a4a16..ea07dc7 100644 --- a/internal/proxy/reverse_test.go +++ b/internal/proxy/reverse_test.go @@ -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 } diff --git a/internal/server/gateway.go b/internal/server/gateway.go index e56dd78..48baf09 100644 --- a/internal/server/gateway.go +++ b/internal/server/gateway.go @@ -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 {