Compare commits

...

33 Commits

Author SHA1 Message Date
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer d8d1b06eee Merge pull request 'fix(gateway): forward the real client IP to the backend on every call (last-login IP was the docker addr)' (#264) from feature/forward-client-ip into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 11s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 09:38:10 +00:00
Ilia Denisov c16008e67a chore(ansible): pin every host's system timezone to UTC
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
The tg (bot) VPS came up on Europe/Moscow while the main host is UTC, so host-level
timestamps (journald, file mtimes, cron) sat 3h apart across the fleet. Add a
community.general.timezone task to the common role so every provisioned host is UTC
(idempotent — a no-op on the already-UTC main host). Applied live to the tg host in
the same change; the containerised services run in UTC regardless, so no restart.
2026-07-14 11:33:39 +02:00
Ilia Denisov ff55d5de83 fix(gateway): forward the real client IP to the backend on every call
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
6 changed files with 88 additions and 2 deletions
+3
View File
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
zap.String("path", route),
zap.Int("status", status),
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)...)
@@ -150,6 +150,13 @@
enabled: true
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) ---------------------------------------------------
# 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
+6 -2
View File
@@ -151,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
(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
of 2 idle connections per host; otherwise the per-request connection churn
exhausts ephemeral ports and burns gateway CPU under load (see
@@ -1496,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
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
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
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
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
+27
View File
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
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;
// 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
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
if 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 != "" {
req.Header.Set("X-Forwarded-For", clientIP)
}
@@ -122,3 +122,44 @@ 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")
}
})
}
+4
View File
@@ -418,6 +418,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
}
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}
if op.Auth {