Refactor reverse proxy and enhance API routing
Publish telemt-api gateway Docker image / test (push) Failing after 26s
Publish telemt-api gateway Docker image / build-and-push (push) Has been skipped

- 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:
Denozordec
2026-03-30 11:47:50 +07:00
parent a52dff4944
commit 374b4e0d71
7 changed files with 211 additions and 147 deletions
+1 -1
View File
@@ -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`.
+2 -24
View File
@@ -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
+125
View File
@@ -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",
}
+30
View File
@@ -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...)
}
+1 -78
View File
@@ -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...)
}
+48 -36
View File
@@ -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
}
+4 -8
View File
@@ -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 {