Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf162de40 | |||
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -56,9 +56,6 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
zap.String("path", route),
|
zap.String("path", route),
|
||||||
zap.Int("status", status),
|
zap.Int("status", status),
|
||||||
zap.Duration("latency", elapsed),
|
zap.Duration("latency", elapsed),
|
||||||
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
|
||||||
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
|
||||||
zap.String("client_ip", c.ClientIP()),
|
|
||||||
}
|
}
|
||||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||||
|
|
||||||
|
|||||||
@@ -150,13 +150,6 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
|
||||||
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
|
||||||
# services themselves run in UTC regardless; this is about host-side log correlation.
|
|
||||||
- name: Set the system timezone to UTC
|
|
||||||
community.general.timezone:
|
|
||||||
name: Etc/UTC
|
|
||||||
|
|
||||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
|||||||
@@ -151,10 +151,7 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
(carried on the request context), so the backend records the real caller — the
|
|
||||||
account's last-login IP, chat/feedback moderation, the access log — rather than
|
|
||||||
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -1499,8 +1496,7 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||||
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||||
last-login IP, chat/feedback moderation and the access log — survives the
|
|
||||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||||
|
|||||||
@@ -139,26 +139,6 @@ func platformFromContext(ctx context.Context) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
|
||||||
type clientIPCtxKey struct{}
|
|
||||||
|
|
||||||
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
|
||||||
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
|
||||||
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
|
||||||
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
|
||||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
|
||||||
if ip == "" {
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
|
||||||
func clientIPFromContext(ctx context.Context) string {
|
|
||||||
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||||
@@ -180,13 +160,6 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", userID)
|
||||||
}
|
}
|
||||||
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
|
||||||
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
|
||||||
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
|
||||||
// connection address.
|
|
||||||
if clientIP == "" {
|
|
||||||
clientIP = clientIPFromContext(ctx)
|
|
||||||
}
|
|
||||||
if clientIP != "" {
|
if clientIP != "" {
|
||||||
req.Header.Set("X-Forwarded-For", clientIP)
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,44 +122,3 @@ func TestXPlatformInjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
|
||||||
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
|
||||||
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
|
||||||
// gateway's own connection. Absent when no client IP is set.
|
|
||||||
func TestXForwardedForInjection(t *testing.T) {
|
|
||||||
var gotXFF string
|
|
||||||
var hadHeader bool
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotXFF = r.Header.Get("X-Forwarded-For")
|
|
||||||
_, hadHeader = r.Header["X-Forwarded-For"]
|
|
||||||
_, _ = w.Write([]byte(`{}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backendclient: %v", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = c.Close() }()
|
|
||||||
|
|
||||||
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
|
||||||
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
|
||||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
|
||||||
t.Fatalf("Profile: %v", err)
|
|
||||||
}
|
|
||||||
if gotXFF != "203.0.113.7" {
|
|
||||||
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no client IP omits the header", func(t *testing.T) {
|
|
||||||
hadHeader = true
|
|
||||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
|
||||||
t.Fatalf("Profile: %v", err)
|
|
||||||
}
|
|
||||||
if hadHeader {
|
|
||||||
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -418,10 +418,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||||
}
|
}
|
||||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||||
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
|
||||||
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
|
||||||
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
|
||||||
ctx = backendclient.WithClientIP(ctx, clientIP)
|
|
||||||
|
|
||||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
|
|||||||
Reference in New Issue
Block a user