From b6c2598710cbc16776a1afa51e1825657307319d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 20:25:41 +0200 Subject: [PATCH 1/8] feat(offer): live catalog price list in the public offer, served by the render sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robokassa moderation requires the public offer to list every digital good with its price. Move /offer/ off the static landing container to the render sidecar: it splices the live catalog price list (§4.4) into the owner-edited ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one renderer, no drift, always matching the current catalog with no redeploy. - backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list) projects the active catalog into two markdown tables — chip packs priced per rail (roubles / VK votes / Telegram Stars) and chip-priced values — through payments.Money so no float reaches the page. Cached in memory: warmed at boot, marked stale on every catalog mutation, so a served render issues no query. - renderer: GET /offer/ fetches the tables and substitutes them at the <#pricing_template#> marker, then renders; offer_ru.md is baked into the image and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed. - caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/ handlers and the vite emit-offer plugin. - offer: fill §4.3 (the chip-payment wording) and drop the in-page back link. - landing footer: a feedback link (the offer's Telegram contact) beside the offer link. - docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe, unit + integration + node tests. --- .gitea/workflows/ci.yaml | 19 ++- backend/cmd/backend/main.go | 7 + .../internal/inttest/payments_catalog_test.go | 42 ++++++ backend/internal/payments/catalog_admin.go | 24 +++- backend/internal/payments/offer.go | 125 ++++++++++++++++++ backend/internal/payments/offer_test.go | 69 ++++++++++ backend/internal/payments/service.go | 8 ++ backend/internal/server/handlers.go | 3 + backend/internal/server/handlers_wallet.go | 16 +++ deploy/caddy/Caddyfile | 9 ++ deploy/docker-compose.yml | 3 + deploy/landing/Caddyfile | 15 +-- docs/ARCHITECTURE.md | 18 ++- docs/FUNCTIONAL.md | 6 +- docs/FUNCTIONAL_ru.md | 7 +- renderer/Dockerfile | 5 +- renderer/README.md | 34 +++-- renderer/build.mjs | 4 + renderer/package.json | 1 + renderer/pnpm-lock.yaml | 10 ++ renderer/src/entry.ts | 11 +- renderer/src/offer.mjs | 17 +++ renderer/src/server.mjs | 51 +++++-- renderer/test/offer.test.mjs | 29 ++++ ui/e2e/landing.spec.ts | 11 +- ui/legal/offer_ru.md | 10 +- ui/src/Landing.svelte | 16 ++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/offer.test.ts | 4 +- ui/src/lib/offer.ts | 20 ++- ui/vite.config.ts | 18 --- 32 files changed, 524 insertions(+), 90 deletions(-) create mode 100644 backend/internal/payments/offer.go create mode 100644 backend/internal/payments/offer_test.go create mode 100644 renderer/src/offer.mjs create mode 100644 renderer/test/offer.test.mjs diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 72d5f62..32eda42 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -510,15 +510,20 @@ jobs: - name: Probe the /offer/ public offer page is served run: | set -u - # /offer/ is a static page baked into the landing image (rendered from - # ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request - # silently falls through to the landing shell (also 200) — so assert offer-specific - # content, never just the status. + # /offer/ is rendered by the render sidecar: it splices the live catalog price list + # (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md. + # If the @offer caddy route is missing, the request falls to the landing shell (also 200), + # and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content + # (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch + + # splice ran), never just the status. Data-independent: an empty catalog still substitutes + # the marker with nothing, so "pricing_template" must be absent either way. out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)" - if echo "$out" | grep -q "290210610742"; then - echo "ok: /offer/ serves the public offer page" + if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then + echo "ok: /offer/ serves the rendered offer with the price list spliced in" else - echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)" + echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)" + docker logs --tail 50 scrabble-renderer || true + docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-landing || true exit 1 fi diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index fa2ac40..1d6b3ee 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -283,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { } logger.Info("payments domain ready") + // Warm the public-offer price list cache so /offer/ serves the current catalog from the first + // request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient + // failure here only defers the projection to the first read. + if _, err := paymentsSvc.OfferPricing(ctx); err != nil { + logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err)) + } + // Wire the payments surface into the domains that consume it: the online-game hint wallet // and the account-merge wallet fold. Done after the reachability check so a broken payments // schema fails boot before anything depends on it. diff --git a/backend/internal/inttest/payments_catalog_test.go b/backend/internal/inttest/payments_catalog_test.go index d50bc57..965fc23 100644 --- a/backend/internal/inttest/payments_catalog_test.go +++ b/backend/internal/inttest/payments_catalog_test.go @@ -4,6 +4,7 @@ package inttest import ( "context" + "strings" "testing" "github.com/google/uuid" @@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) { t.Error("deactivated product must not appear in the storefront") } } + +// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from +// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears +// with its per-rail prices, and archiving it through the service drops it from the next read. +func TestOfferPricingReflectsCatalogEdits(t *testing.T) { + svc := newPaymentsService() + ctx := context.Background() + title := "OfferTest " + uuid.NewString() + id, err := svc.CreateProduct(ctx, payments.ProductInput{ + Title: title, + Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}}, + Prices: []payments.PriceLine{ + {Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000}, + {Method: "vk", Currency: payments.CurrencyVote, Amount: 30}, + {Method: "telegram", Currency: payments.CurrencyStar, Amount: 100}, + }, + }, true) + if err != nil { + t.Fatalf("create pack: %v", err) + } + + md, err := svc.OfferPricing(ctx) + if err != nil { + t.Fatalf("offer pricing: %v", err) + } + if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) { + t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md) + } + + // Archiving through the service marks the cache stale; the next read must reproject without it. + if err := svc.SetProductActive(ctx, id, false); err != nil { + t.Fatalf("archive: %v", err) + } + md, err = svc.OfferPricing(ctx) + if err != nil { + t.Fatalf("offer pricing after archive: %v", err) + } + if strings.Contains(md, title) { + t.Errorf("archived pack must drop from the offer pricing:\n%s", md) + } +} diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index 999cbfb..401ec98 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -153,7 +153,11 @@ func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active boo if err := validateProduct(in, active); err != nil { return uuid.Nil, err } - return s.store.createProduct(ctx, in, active, s.clock()) + id, err := s.store.createProduct(ctx, in, active, s.clock()) + if err == nil { + s.markOfferStale() + } + return id, err } // UpdateProduct validates and replaces a product's title, atoms and prices. An active product must @@ -166,7 +170,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp if err := validateProduct(in, active); err != nil { return err } - return s.store.updateProduct(ctx, id, in, s.clock()) + if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil { + return err + } + s.markOfferStale() + return nil } // SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the @@ -181,11 +189,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo return err } } - return s.store.setProductActive(ctx, id, active, s.clock()) + if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil { + return err + } + s.markOfferStale() + return nil } // DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger // row references it); otherwise it returns ErrProductTransacted and the caller archives instead. func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error { - return s.store.deleteProduct(ctx, id) + if err := s.store.deleteProduct(ctx, id); err != nil { + return err + } + s.markOfferStale() + return nil } diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go new file mode 100644 index 0000000..6fa7f32 --- /dev/null +++ b/backend/internal/payments/offer.go @@ -0,0 +1,125 @@ +package payments + +import ( + "context" + "fmt" + "strings" +) + +// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries +// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing] +// returns before rendering the /offer/ page; the backend only produces the tables, never the marker. +const pricingMarker = "<#pricing_template#>" + +// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the +// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK +// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The +// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]), +// so a steady-state read issues no query — only the first read after an edit reprojects. The render +// sidecar fetches it and splices it into the offer markdown at the pricing marker. +func (s *Service) OfferPricing(ctx context.Context) (string, error) { + s.offerMu.Lock() + defer s.offerMu.Unlock() + if s.offerFresh { + return s.offerMD, nil + } + md, err := s.buildOfferPricing(ctx) + if err != nil { + return "", err + } + s.offerMD = md + s.offerFresh = true + return md, nil +} + +// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing] +// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it. +func (s *Service) markOfferStale() { + s.offerMu.Lock() + s.offerFresh = false + s.offerMu.Unlock() +} + +// buildOfferPricing loads the active catalog and projects it into the offer tables. +func (s *Service) buildOfferPricing(ctx context.Context) (string, error) { + entries, err := s.store.loadCatalog(ctx) + if err != nil { + return "", err + } + return projectOfferPricing(entries), nil +} + +// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries +// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip +// price. Rows keep the catalog's creation order. An empty section is omitted. Amounts are rendered +// through [Money] so no floating point ever reaches the page (roubles show kopecks as "200.00", +// whole-unit rails as integers); a missing rail price shows an em dash. +func projectOfferPricing(entries []catalogEntry) string { + var packs, values []string + for _, e := range entries { + if isPackEntry(e) { + packs = append(packs, fmt.Sprintf("| %s | %s | %s | %s |", + offerCell(e.title), + offerPrice(e, string(SourceDirect), CurrencyRUB), + offerPrice(e, string(SourceVK), CurrencyVote), + offerPrice(e, string(SourceTelegram), CurrencyStar), + )) + } else { + values = append(values, fmt.Sprintf("| %s | %s |", + offerCell(e.title), offerPrice(e, "", CurrencyChip))) + } + } + + var b strings.Builder + if len(packs) > 0 { + b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") + b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") + b.WriteString("| --- | --- | --- | --- |\n") + b.WriteString(strings.Join(packs, "\n")) + b.WriteString("\n") + } + if len(values) > 0 { + if len(packs) > 0 { + b.WriteString("\n") + } + b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") + b.WriteString("| Наименование | «Фишки» |\n") + b.WriteString("| --- | --- |\n") + b.WriteString(strings.Join(values, "\n")) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds +// chips with money) rather than being a chip-priced value. +func isPackEntry(e catalogEntry) bool { + for _, a := range e.atoms { + if a.atomType == atomChips { + return true + } + } + return false +} + +// offerPrice formats the entry's price for the given payment method and currency as a major-unit +// string, or an em dash when the entry carries no such price. +func offerPrice(e catalogEntry, method string, cur Currency) string { + for _, pr := range e.prices { + if pr.method == method && pr.currency == cur { + m, err := MoneyFromMinor(pr.amount, pr.currency) + if err != nil { + return "—" + } + return m.Major() + } + } + return "—" +} + +// offerCell escapes an owner-entered title for a markdown table cell: a literal pipe would break the +// column layout, and a newline would break the row. +func offerCell(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + return strings.ReplaceAll(s, "|", "\\|") +} diff --git a/backend/internal/payments/offer_test.go b/backend/internal/payments/offer_test.go new file mode 100644 index 0000000..f387ae1 --- /dev/null +++ b/backend/internal/payments/offer_test.go @@ -0,0 +1,69 @@ +package payments + +import ( + "strings" + "testing" + + "github.com/google/uuid" +) + +// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced +// value render into the two tables, pack table first, money formatted through Money. +func TestProjectOfferPricing(t *testing.T) { + entries := []catalogEntry{ + { + id: uuid.New(), + title: "50 «Фишек»", + atoms: []atomQty{{atomType: atomChips, quantity: 50}}, + prices: []priceRow{ + {method: string(SourceDirect), currency: CurrencyRUB, amount: 20000}, + {method: string(SourceVK), currency: CurrencyVote, amount: 30}, + {method: string(SourceTelegram), currency: CurrencyStar, amount: 100}, + }, + }, + { + id: uuid.New(), + title: "200 подсказок", + atoms: []atomQty{{atomType: "hints", quantity: 200}}, + prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}}, + }, + } + md := projectOfferPricing(entries) + for _, want := range []string{ + "| Наименование | Рубли | Голоса в VK | Stars в Telegram |", + "| 50 «Фишек» | 200.00 | 30 | 100 |", + "| Наименование | «Фишки» |", + "| 200 подсказок | 50 |", + } { + if !strings.Contains(md, want) { + t.Errorf("projection missing %q\n---\n%s", want, md) + } + } + if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") { + t.Errorf("the pack table must precede the values table:\n%s", md) + } +} + +// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a +// title carrying a pipe is escaped so the table layout survives. +func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { + entries := []catalogEntry{{ + id: uuid.New(), + title: "Bonus | pack", + atoms: []atomQty{{atomType: atomChips, quantity: 10}}, + // A roubles price only — no VK, no Telegram. + prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}}, + }} + md := projectOfferPricing(entries) + if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) { + t.Errorf("want row %q in:\n%s", want, md) + } +} + +// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table +// headers), so the offer's pricing marker is replaced with nothing. +func TestProjectOfferPricingEmpty(t *testing.T) { + if got := projectOfferPricing(nil); got != "" { + t.Errorf("empty catalog must project to empty string, got %q", got) + } +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go index a0c55ee..cfab16f 100644 --- a/backend/internal/payments/service.go +++ b/backend/internal/payments/service.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "sync" "time" "github.com/google/uuid" @@ -19,6 +20,13 @@ import ( type Service struct { store *Store clock func() time.Time + + // offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected + // markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh + // (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query. + offerMu sync.Mutex + offerMD string + offerFresh bool } // NewService constructs a Service over store with a wall-clock time source. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 197e764..2e3f114 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -74,6 +74,9 @@ func (s *Server) registerRoutes() { u.POST("/wallet/buy", s.handleWalletBuy) // A rewarded-video credit (VK ads): client-attested + a config daily cap. u.POST("/wallet/reward", s.handleWalletReward) + // The public-offer price list (§4.4) as markdown, for the render sidecar that serves the + // /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway. + s.internal.GET("/offer/pricing", s.handleOfferPricing) } if s.payments != nil { // The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an diff --git a/backend/internal/server/handlers_wallet.go b/backend/internal/server/handlers_wallet.go index 89f68e7..0424086 100644 --- a/backend/internal/server/handlers_wallet.go +++ b/backend/internal/server/handlers_wallet.go @@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) { c.JSON(http.StatusOK, catalogDTOFrom(view)) } +// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables +// projected from the active products. The render sidecar fetches it and splices it into the offer +// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is +// off the edge allow-list, and the value is served from the payments cache (no per-request query in +// the steady state). Called by the renderer, not the gateway. +func (s *Server) handleOfferPricing(c *gin.Context) { + md, err := s.payments.OfferPricing(c.Request.Context()) + if err != nil { + s.log.Error("offer pricing projection failed", zap.Error(err)) + c.String(http.StatusInternalServerError, "offer pricing unavailable") + return + } + c.Header("Content-Type", "text/markdown; charset=utf-8") + c.String(http.StatusOK, md) +} + // handleWallet returns the caller's wallet — the segments and benefits visible in the current // trusted execution context, plus the rewarded-video payout available here (0 outside VK or when // unconfigured), which gates the client's "watch for chips" button. diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 1b19493..8a9ab64 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -107,6 +107,15 @@ } } + # The public offer page is rendered by the render sidecar: it splices the live catalog price + # list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only + # /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept + # disjoint from the landing/app paths so the catch-all below never shadows it. + @offer path /offer /offer/* + handle @offer { + reverse_proxy renderer:8090 + } + # Everything else — the public landing at / and any stray path — is static. handle { reverse_proxy landing:80 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fe508ba..cb61d2b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -84,6 +84,9 @@ services: logging: *default-logging environment: RENDERER_PORT: "8090" + # The offer page (GET /offer/) fetches the live catalog price list from the backend's internal + # endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502. + RENDERER_BACKEND_URL: http://backend:8080 healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 10s diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile index d8747b7..4a3d66a 100644 --- a/deploy/landing/Caddyfile +++ b/deploy/landing/Caddyfile @@ -18,18 +18,9 @@ @shell not path /assets/* header @shell Cache-Control "no-cache" - # The static public offer page, rendered from ui/legal/offer_ru.md at build - # time into dist/offer/index.html (vite emit-offer plugin). Served with its own - # index so /offer/ resolves to /srv/offer/index.html rather than falling to the - # landing shell below (whose index is landing.html). A bare /offer redirects in. - handle /offer { - redir * /offer/ permanent - } - handle /offer/* { - file_server { - index index.html - } - } + # The public offer page (/offer/) is no longer served here: the contour caddy routes it to the + # render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This + # container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone). # An unknown path falls back to the landing shell (the gateway's old "/" # behaviour); "/" itself resolves through the index below. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 540f210..948df6a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -909,6 +909,17 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl predating `downloadFile`, where the GCG falls back to the old clipboard copy and the image option is not offered. +The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed +route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared +`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited +`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it +fetches the two catalog tables as markdown from the backend's internal +`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The +backend projects the tables from the active catalog through `payments.Money` (no float reaches the +page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served +render issues no query in the steady state and the page always reflects the current catalog without +a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. + The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — replay, history, GCG) keeps the decoded concrete letters described above, so an archived @@ -1349,9 +1360,10 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered -**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the -catch-all — notably the landing at `/`, plus the static public offer at `/offer/` -(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The +**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/` +(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in) +goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing +container. The **Telegram validator** runs as a separate container with **no public ingress**, answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index d93dc5e..5da59fe 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -30,7 +30,11 @@ render with arbitrary languages, so detection would make the indexed content nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head (title/description, the Open Graph card Telegram/VK link previews use, a canonical link pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA -shell is `noindex` — the landing is the only indexable page. +shell is `noindex` — the landing is the only indexable page. The footer links to the **public +offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's +contact. The offer page (the legal document a purchase accepts) is rendered on demand from +`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so +the published prices always match what is currently on sale — no redeploy to update them. On the plain web the client is an **installable PWA**: a logged-out player sees an install call-to-action under the login form (and at the bottom of Settings) that installs the app to the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7e27cf5..c39dd0c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -32,7 +32,12 @@ top-1 подсказку, безлимитную проверку слова с главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена -`noindex` — индексируется только посадочная страница. +`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную +оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта +указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при +покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4) +формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что +сейчас в продаже — без передеплоя. В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол diff --git a/renderer/Dockerfile b/renderer/Dockerfile index d083897..65905a7 100644 --- a/renderer/Dockerfile +++ b/renderer/Dockerfile @@ -23,7 +23,10 @@ ENV APP_VERSION=${VERSION} WORKDIR /app COPY --from=build /src/renderer/node_modules ./node_modules COPY --from=build /src/renderer/dist ./dist -COPY renderer/src/server.mjs renderer/src/render.mjs ./src/ +COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/ +# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic +# price list is fetched from the backend at request time and spliced in. +COPY ui/legal/offer_ru.md ./legal/offer_ru.md USER node EXPOSE 8090 CMD ["node", "src/server.mjs"] diff --git a/renderer/README.md b/renderer/README.md index 79a25e4..a219e1a 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -1,10 +1,10 @@ -# renderer — the finished-game image-render sidecar +# renderer — the render sidecar (finished-game image + public offer page) -An internal-only Node service that rasterizes the finished-game export PNG. It runs the -**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image -build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on -[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is -pixel-identical to the design the owner signed off in the browser. +An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at +image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no +drift from the browser. It serves two surfaces: the finished-game export **PNG** (on +[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner +signed off) and the public **offer page**. ## Interface @@ -12,20 +12,30 @@ pixel-identical to the design the owner signed off in the browser. `image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`); `alphabet` is the per-variant `(index, letter, value)` table tile values are drawn from; `labels` localizes the non-play moves (pass/exchange/resign/timeout); - `hostname` + `dateLocale` feed the footer. + `hostname` + `dateLocale` feed the footer. Internal-only. +- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md` + (baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as + markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`) + and spliced in at the `<#pricing_template#>` marker, then rendered by the shared + `ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy + routes `/offer/` here; a backend outage yields a 502, never a stale price list. - `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on). -The service draws and nothing else: authentication, the participant check and the signed -public download URL all live in the backend (`backend/internal/server/export.go`); the -network path is client → gateway `/dl/*` → backend → this sidecar. +The service renders and nothing else: for the PNG, authentication, the participant check and the +signed public download URL all live in the backend (`backend/internal/server/export.go`), the path +being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and +its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the +page. ## Development ```sh pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml # (allowBuilds) or the native binary is silently never fetched -pnpm test # bundles, then node --test against testdata/request.json -node src/server.mjs # local run on :8090 (RENDERER_PORT overrides) +pnpm test # bundles, then node --test (PNG smoke + offer splice) +# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a +# reachable backend (else the boot read / the price fetch fail): +RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs ``` The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the diff --git a/renderer/build.mjs b/renderer/build.mjs index 23423c6..775e6ef 100644 --- a/renderer/build.mjs +++ b/renderer/build.mjs @@ -9,5 +9,9 @@ await build({ format: 'esm', platform: 'node', outfile: 'dist/gameimage.mjs', + // The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they + // make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui + // tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency. + nodePaths: ['node_modules'], logLevel: 'info', }); diff --git a/renderer/package.json b/renderer/package.json index c791dfd..428754f 100644 --- a/renderer/package.json +++ b/renderer/package.json @@ -9,6 +9,7 @@ "test": "pnpm run bundle && node --test test/*.test.mjs" }, "dependencies": { + "marked": "^18.0.5", "skia-canvas": "^3.0.6" }, "devDependencies": { diff --git a/renderer/pnpm-lock.yaml b/renderer/pnpm-lock.yaml index 32027d3..0425c84 100644 --- a/renderer/pnpm-lock.yaml +++ b/renderer/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + marked: + specifier: ^18.0.5 + version: 18.0.6 skia-canvas: specifier: ^3.0.6 version: 3.0.8 @@ -209,6 +212,11 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + marked@18.0.6: + resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} + engines: {node: '>= 20'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -347,6 +355,8 @@ snapshots: transitivePeerDependencies: - supports-color + marked@18.0.6: {} + ms@2.1.3: {} parenthesis@3.1.8: {} diff --git a/renderer/src/entry.ts b/renderer/src/entry.ts index 2b1a954..2e6c8e0 100644 --- a/renderer/src/entry.ts +++ b/renderer/src/entry.ts @@ -1,6 +1,9 @@ -// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib — -// the exact module the browser build unit-tests — plus the alphabet cache seeder the -// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle` -// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source). +// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the +// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle` +// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source): +// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render). +// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build +// used to invoke, now server-side so the live catalog price list can be spliced in. export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage'; export { setAlphabet } from '../../ui/src/lib/alphabet'; +export { renderOfferHtml } from '../../ui/src/lib/offer'; diff --git a/renderer/src/offer.mjs b/renderer/src/offer.mjs new file mode 100644 index 0000000..f464d6f --- /dev/null +++ b/renderer/src/offer.mjs @@ -0,0 +1,17 @@ +// The public-offer page renderer: splices the live catalog price list into the owner-edited offer +// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled +// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP. +import { renderOfferHtml } from '../dist/gameimage.mjs'; + +// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It +// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched +// tables before rendering. +export const pricingMarker = '<#pricing_template#>'; + +// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker +// replaced by pricingTables (the two markdown tables the backend projects from the active catalog), +// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$" +// in a product title is never read as a replacement pattern; a missing marker leaves the text as is. +export function renderOffer(offerMarkdown, pricingTables) { + return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables)); +} diff --git a/renderer/src/server.mjs b/renderer/src/server.mjs index 84ea98c..bd0edac 100644 --- a/renderer/src/server.mjs +++ b/renderer/src/server.mjs @@ -1,20 +1,43 @@ -// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the -// finished-game export image. It runs the same drawGameImage the browser build ships -// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from -// the image (Liberation Sans + Noto Color Emoji via fontconfig). +// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib +// renderers (bundled from ui/src/lib at image build time). It serves two surfaces: // // POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png +// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog +// price list spliced in — the only edge-exposed route; caddy routes /offer/ here) +// GET /offer → 301 /offer/ // GET /healthz → 200 // -// The service is internal-only (docker network `internal`); authentication, participant -// checks and the signed public URL all live in the backend — this process only draws. +// /render is internal-only (the backend calls it; authentication, participant checks and the signed +// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged +// — it fetches the price list from the backend's internal endpoint and renders the committed offer +// markdown; no user input reaches it. import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; import { renderRequest } from './render.mjs'; +import { renderOffer } from './offer.mjs'; const PORT = Number(process.env.RENDERER_PORT || 8090); // A render request is a finished game's journal — generously capped. const MAX_BODY = 2 * 1024 * 1024; +// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the +// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge +// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked +// path for a local run (point it at ../ui/legal/offer_ru.md). +const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8'); +const OFFER_PRICING_URL = + (process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing'; + +// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a +// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list. +async function fetchOfferPricing() { + const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) { + throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 }); + } + return resp.text(); +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -35,11 +58,23 @@ function readBody(req) { const server = createServer(async (req, res) => { try { - if (req.method === 'GET' && req.url === '/healthz') { + const path = (req.url || '').split('?')[0]; + if (req.method === 'GET' && path === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }).end('ok'); return; } - if (req.method === 'POST' && req.url === '/render') { + if (req.method === 'GET' && path === '/offer') { + res.writeHead(301, { location: '/offer/' }).end(); + return; + } + if (req.method === 'GET' && path === '/offer/') { + const html = renderOffer(OFFER_MD, await fetchOfferPricing()); + res + .writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }) + .end(html); + return; + } + if (req.method === 'POST' && path === '/render') { const png = await renderRequest(JSON.parse(await readBody(req))); res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png); return; diff --git a/renderer/test/offer.test.mjs b/renderer/test/offer.test.mjs new file mode 100644 index 0000000..a82cf5b --- /dev/null +++ b/renderer/test/offer.test.mjs @@ -0,0 +1,29 @@ +// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list +// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml +// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts); +// here we assert the substitution and that the tables reach the rendered HTML. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { renderOffer, pricingMarker } from '../src/offer.mjs'; + +const tables = + '| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' + + '| --- | --- | --- | --- |\n' + + '| 50 «Фишек» | 200.00 | 30 | 100 |'; + +test('splices the price list into the offer and renders the tables to HTML', () => { + const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`; + const html = renderOffer(md, tables); + // The marker is gone and the projected table reached the document as a real HTML table. + assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted'); + assert.ok(html.includes(''), 'the price list must render as an HTML table'); + assert.ok(html.includes(''), 'a rouble price cell must be present'); + assert.ok(html.includes('50 «Фишек»'), 'the product title must be present'); + // The offer chrome from the shared renderer is intact. + assert.ok(html.includes('')); +}); + +test('a "$" in a title is substituted literally, not as a replacement pattern', () => { + const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |'); + assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution'); +}); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index 4ce19bf..4918bfb 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -59,13 +59,18 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn await expect(page.getByText('Веб-версия')).toBeVisible(); }); -// The footer carries a public-offer link to the static /offer/ page (rendered from -// ui/legal/offer_ru.md at build; the legal document a purchase accepts). -test('the landing footer links to the public offer at /offer/', async ({ page }) => { +// The footer carries the public-offer link (/offer/ — the legal document a purchase accepts, +// rendered by the render sidecar) and, beside it, the feedback link to the Telegram bot the offer +// lists as the seller's contact. +test('the landing footer links to the public offer and the feedback bot', async ({ page }) => { await page.goto('/landing.html'); await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default const offer = page.getByRole('link', { name: 'Публичная оферта' }); await expect(offer).toBeVisible(); expect(await offer.getAttribute('href')).toBe('/offer/'); + + const feedback = page.getByRole('link', { name: 'Обратная связь' }); + await expect(feedback).toBeVisible(); + expect(await feedback.getAttribute('href')).toBe('https://t.me/Erudit_GameBot'); }); diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md index 39af269..2e2b866 100644 --- a/ui/legal/offer_ru.md +++ b/ui/legal/offer_ru.md @@ -80,6 +80,12 @@ **4.2.** Все расчеты по Договору производятся в безналичном порядке. +**4.3.** Порядок расчётов. Приобретаемым Товаром является внутриигровая валюта «Фишка» — условная учётная единица, используемая исключительно в пределах Сайта Продавца. «Фишки» предоставляют Покупателю возможность получения внутриигровых благ и дополнительных функций, включая, но не ограничиваясь: отказ от показа рекламы, приобретение подсказок в игре и иные внутриигровые возможности. «Фишка» не является электронным средством платежа либо денежным средством, не подлежит обмену на денежные средства и не может быть использована за пределами Сайта Продавца. «Фишки» зачисляются на внутриигровой счёт Покупателя единовременно после поступления оплаты; дальнейшее их использование для получения внутриигровых благ осуществляется Покупателем самостоятельно в интерфейсе игры. + +**4.4.** Стоимость Товаров: + +<#pricing_template#> + ## 5. Обмен и возврат Товара **5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463. @@ -120,7 +126,7 @@ **9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору. -**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме. +**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме при оплате Товаров. ## 10. Дополнительные условия @@ -143,3 +149,5 @@ ## 11. Реквизиты Продавца Денисов Илья Аркадьевич, ИНН 290210610742. + +Обратная связь в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot). diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 4fa71a0..0795ba3 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -125,7 +125,13 @@ @@ -287,6 +293,14 @@ color: var(--text-muted); font-size: 0.8rem; } + .ft .legal { + display: flex; + align-items: center; + gap: 8px; + } + .ft .sep { + color: var(--text-muted); + } .ft .offer { color: inherit; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1e6334e..d3a50de 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -285,6 +285,7 @@ export const en = { 'landing.captionVK': 'VK', 'landing.captionWeb': 'Web', 'landing.offer': 'Public offer', + 'landing.feedback': 'Feedback', 'install.title': 'Install the app', 'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index a9df0cd..b177751 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -285,6 +285,7 @@ export const ru: Record = { 'landing.captionVK': 'VK', 'landing.captionWeb': 'Веб-версия', 'landing.offer': 'Публичная оферта', + 'landing.feedback': 'Обратная связь', 'install.title': 'Установить приложение', 'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.', diff --git a/ui/src/lib/offer.test.ts b/ui/src/lib/offer.test.ts index a9da8c3..e46cf3b 100644 --- a/ui/src/lib/offer.test.ts +++ b/ui/src/lib/offer.test.ts @@ -10,8 +10,8 @@ describe('renderOfferHtml', () => { // The markdown heading is rendered, not left as literal source. expect(html).toContain('

Публичная оферта

'); expect(html).not.toContain('# Публичная оферта'); - // A back link to the landing root is always present. - expect(html).toContain('href="/"'); + // No in-page navigation: the standalone offer carries no "back" link. + expect(html).not.toContain('class="back"'); }); it('renders headings, bold clause numbers and lists', () => { diff --git a/ui/src/lib/offer.ts b/ui/src/lib/offer.ts index f6cecd9..4d0d969 100644 --- a/ui/src/lib/offer.ts +++ b/ui/src/lib/offer.ts @@ -2,13 +2,15 @@ import { marked } from 'marked'; /** * renderOfferHtml renders the public-offer markdown source into a standalone, - * self-contained HTML document served statically at `/offer/`. The build emits - * the result as `dist/offer/index.html` (see the `emit-offer` plugin in - * `vite.config.ts`), which the landing container serves. + * self-contained HTML document served at `/offer/`. It runs server-side in the + * render sidecar (`renderer/src/offer.mjs`), which reads the owner-edited + * `ui/legal/offer_ru.md`, splices the live catalog price list into it and calls + * this function — one renderer shared with the browser build, kept unit-tested + * here (`offer.test.ts`). * - * The input `markdown` is trusted repository content (the owner-edited - * `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately - * not sanitised. The page carries its own minimal light/dark styling so it needs + * The input `markdown` is trusted repository content plus the backend's own + * catalog projection, not user input, so the rendered HTML is deliberately not + * sanitised. The page carries its own minimal light/dark styling so it needs * neither the app bundle nor `app.css`. */ export function renderOfferHtml(markdown: string): string { @@ -55,11 +57,6 @@ export function renderOfferHtml(markdown: string): string { a { color: var(--accent); } - .back { - display: inline-block; - margin-bottom: 20px; - text-decoration: none; - } h1 { font-size: 1.6rem; text-align: center; @@ -84,7 +81,6 @@ export function renderOfferHtml(markdown: string): string {
- ← На главную ${body}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 62a6217..5a36909 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,7 +3,6 @@ import { resolve } from 'node:path'; import { defineConfig, type Plugin } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { VitePWA } from 'vite-plugin-pwa'; -import { renderOfferHtml } from './src/lib/offer'; /** * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its @@ -39,22 +38,6 @@ function emitPolyfills(): Plugin { }; } -/** - * emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone - * static page and emits it as dist/offer/index.html, which the landing container - * serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable - * source of truth, so the page is regenerated from it on every build. - */ -function emitOffer(): Plugin { - return { - name: 'emit-offer', - generateBundle() { - const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8'); - this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) }); - }, - }; -} - // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // not speak h2c directly) talks to the dev server on the same origin. In `mock` @@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({ plugins: [ svelte(), emitPolyfills(), - emitOffer(), injectBootVersion(), // App-shell precache for the offline mode: a custom (injectManifest) service worker precaches // index.html + the hashed assets so the installed web PWA cold-launches with no network. It From b54371845f72f0c325a3149614cd78ec62495843 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 20:30:36 +0200 Subject: [PATCH 2/8] harden(offer): escape admin product titles against HTML/markdown injection The offer price list projects the admin-entered product title into a markdown table cell that is rendered, unsanitised, into the public /offer/ page. Escape the title at the projection boundary so it renders as literal text: HTML metacharacters become entities and the markdown table pipe and link brackets are escaped, so a title can neither inject markup nor form a javascript: link. The committed offer prose keeps its trusted-content treatment (code-reviewed, not runtime input). --- backend/internal/payments/offer.go | 27 +++++++++++++++++++++---- backend/internal/payments/offer_test.go | 24 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index 6fa7f32..ee48c50 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -117,9 +117,28 @@ func offerPrice(e catalogEntry, method string, cur Currency) string { return "—" } -// offerCell escapes an owner-entered title for a markdown table cell: a literal pipe would break the -// column layout, and a newline would break the row. +// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as +// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows +// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not +// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the +// page), the markdown table pipe and the row newline, and the link brackets (a title must never +// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the +// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not +// double-escape the entities the other rules emit. +var offerCellReplacer = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + "|", `\|`, + "[", `\[`, + "]", `\]`, + "\n", " ", +) + +// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of +// the public offer (see [offerCellReplacer]). func offerCell(s string) string { - s = strings.ReplaceAll(s, "\n", " ") - return strings.ReplaceAll(s, "|", "\\|") + return offerCellReplacer.Replace(s) } diff --git a/backend/internal/payments/offer_test.go b/backend/internal/payments/offer_test.go index f387ae1..0173723 100644 --- a/backend/internal/payments/offer_test.go +++ b/backend/internal/payments/offer_test.go @@ -60,6 +60,30 @@ func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { } } +// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link +// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the +// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not +// survive into the projected markdown. +func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) { + entries := []catalogEntry{{ + id: uuid.New(), + title: ` [x](javascript:alert(2)) & "q"`, + atoms: []atomQty{{atomType: "hints", quantity: 1}}, + prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}}, + }} + md := projectOfferPricing(entries) + for _, bad := range []string{"", "[x]", `& "q"`} { + if strings.Contains(md, bad) { + t.Errorf("unescaped %q survived into the projection:\n%s", bad, md) + } + } + for _, want := range []string{"<script>", `\[x\]`, "&", ""q""} { + if !strings.Contains(md, want) { + t.Errorf("want escaped %q in:\n%s", want, md) + } + } +} + // TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table // headers), so the offer's pricing marker is replaced with nothing. func TestProjectOfferPricingEmpty(t *testing.T) { From 40acbcccdd9d43a20e29ff2dd792cba985a3749e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 11:28:56 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(offer):=20sort=20and=20align=20the=20?= =?UTF-8?q?=C2=A74.4=20price=20tables,=20style=20them=20for=20both=20theme?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packs sorted by ascending rouble price; - values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament (the tournament group is empty until such products become sellable), ascending chip price within each group; - price columns right-aligned (GFM "---:" separators); - tables span the full content width; the name column shrinks to its content and never wraps; - muted-but-visible cell borders on the dark theme, where the section rule colour blends into the background. --- backend/internal/payments/offer.go | 116 +++++++++++++++++++----- backend/internal/payments/offer_test.go | 44 +++++++++ docs/ARCHITECTURE.md | 7 +- ui/src/lib/offer.ts | 29 ++++++ 4 files changed, 171 insertions(+), 25 deletions(-) diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index ee48c50..0fc4cc0 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -1,8 +1,11 @@ package payments import ( + "cmp" "context" "fmt" + "math" + "slices" "strings" ) @@ -51,32 +54,46 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) { // projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries // the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip -// price. Rows keep the catalog's creation order. An empty section is omitted. Amounts are rendered -// through [Money] so no floating point ever reaches the page (roubles show kopecks as "200.00", -// whole-unit rails as integers); a missing rail price shows an em dash. +// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints +// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within +// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is +// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles +// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash. func projectOfferPricing(entries []catalogEntry) string { - var packs, values []string + var packs, values []catalogEntry for _, e := range entries { if isPackEntry(e) { - packs = append(packs, fmt.Sprintf("| %s | %s | %s | %s |", - offerCell(e.title), - offerPrice(e, string(SourceDirect), CurrencyRUB), - offerPrice(e, string(SourceVK), CurrencyVote), - offerPrice(e, string(SourceTelegram), CurrencyStar), - )) + packs = append(packs, e) } else { - values = append(values, fmt.Sprintf("| %s | %s |", - offerCell(e.title), offerPrice(e, "", CurrencyChip))) + values = append(values, e) } } + // Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price + // sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties. + slices.SortStableFunc(packs, func(a, b catalogEntry) int { + return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB)) + }) + slices.SortStableFunc(values, func(a, b catalogEntry) int { + if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 { + return d + } + return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip)) + }) + var b strings.Builder if len(packs) > 0 { b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n") - b.WriteString("| --- | --- | --- | --- |\n") - b.WriteString(strings.Join(packs, "\n")) - b.WriteString("\n") + b.WriteString("| --- | ---: | ---: | ---: |\n") + for _, e := range packs { + fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", + offerCell(e.title), + offerPrice(e, string(SourceDirect), CurrencyRUB), + offerPrice(e, string(SourceVK), CurrencyVote), + offerPrice(e, string(SourceTelegram), CurrencyStar), + ) + } } if len(values) > 0 { if len(packs) > 0 { @@ -84,13 +101,54 @@ func projectOfferPricing(entries []catalogEntry) string { } b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n") b.WriteString("| Наименование | «Фишки» |\n") - b.WriteString("| --- | --- |\n") - b.WriteString(strings.Join(values, "\n")) - b.WriteString("\n") + b.WriteString("| --- | ---: |\n") + for _, e := range values { + fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip)) + } } return strings.TrimRight(b.String(), "\n") } +// offerValueGroup ranks a chip-priced value into the offer's usage groups, in listing order: hints +// only (0), no-ads only (1), no-ads + hints (2), then anything carrying the tournament atom (3). +// Tournament products are not sellable yet (validateProduct forbids an active one), so group 3 is +// empty today; the rank reserves their place for when the tournament economy lands. A value with no +// recognised benefit atom sorts after the known groups (defensive — the catalog shape forbids it). +func offerValueGroup(e catalogEntry) int { + hasHints, hasNoAds, hasTournament := false, false, false + for _, a := range e.atoms { + switch a.atomType { + case "hints": + hasHints = true + case "noads_days": + hasNoAds = true + case "tournament": + hasTournament = true + } + } + switch { + case hasTournament: + return 3 + case hasHints && hasNoAds: + return 2 + case hasNoAds: + return 1 + case hasHints: + return 0 + default: + return 4 + } +} + +// offerSortAmount returns the entry's price in the given method and currency for ordering, or +// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading. +func offerSortAmount(e catalogEntry, method string, cur Currency) int64 { + if amt, ok := offerAmount(e, method, cur); ok { + return amt + } + return math.MaxInt64 +} + // isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds // chips with money) rather than being a chip-priced value. func isPackEntry(e catalogEntry) bool { @@ -105,16 +163,26 @@ func isPackEntry(e catalogEntry) bool { // offerPrice formats the entry's price for the given payment method and currency as a major-unit // string, or an em dash when the entry carries no such price. func offerPrice(e catalogEntry, method string, cur Currency) string { + amt, ok := offerAmount(e, method, cur) + if !ok { + return "—" + } + m, err := MoneyFromMinor(amt, cur) + if err != nil { + return "—" + } + return m.Major() +} + +// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and +// currency, and whether such a price exists. +func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) { for _, pr := range e.prices { if pr.method == method && pr.currency == cur { - m, err := MoneyFromMinor(pr.amount, pr.currency) - if err != nil { - return "—" - } - return m.Major() + return pr.amount, true } } - return "—" + return 0, false } // offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as diff --git a/backend/internal/payments/offer_test.go b/backend/internal/payments/offer_test.go index 0173723..614fc65 100644 --- a/backend/internal/payments/offer_test.go +++ b/backend/internal/payments/offer_test.go @@ -31,8 +31,10 @@ func TestProjectOfferPricing(t *testing.T) { md := projectOfferPricing(entries) for _, want := range []string{ "| Наименование | Рубли | Голоса в VK | Stars в Telegram |", + "| --- | ---: | ---: | ---: |", // price columns right-aligned "| 50 «Фишек» | 200.00 | 30 | 100 |", "| Наименование | «Фишки» |", + "| --- | ---: |", "| 200 подсказок | 50 |", } { if !strings.Contains(md, want) { @@ -44,6 +46,48 @@ func TestProjectOfferPricing(t *testing.T) { } } +// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by +// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group. +func TestProjectOfferPricingOrdering(t *testing.T) { + pack := func(title string, rub int64) catalogEntry { + return catalogEntry{ + id: uuid.New(), + title: title, + atoms: []atomQty{{atomType: atomChips, quantity: 1}}, + prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}}, + } + } + value := func(title string, chips int64, atoms ...string) catalogEntry { + e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}} + for _, a := range atoms { + e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1}) + } + return e + } + // Deliberately out of order on input. + entries := []catalogEntry{ + pack("packDear", 30000), + pack("packCheap", 10000), + value("bundle", 500, "hints", "noads_days"), + value("adsOnly", 150, "noads_days"), + value("hintsBig", 200, "hints"), + value("hintsSmall", 50, "hints"), + } + md := projectOfferPricing(entries) + order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"} + last := -1 + for _, title := range order { + i := strings.Index(md, "| "+title+" |") + if i < 0 { + t.Fatalf("row %q missing:\n%s", title, md) + } + if i < last { + t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md) + } + last = i + } +} + // TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a // title carrying a pipe is escaped so the table layout survives. func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 948df6a..fbd26d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -918,7 +918,12 @@ fetches the two catalog tables as markdown from the backend's internal backend projects the tables from the active catalog through `payments.Money` (no float reaches the page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served render issues no query in the steady state and the page always reflects the current catalog without -a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. +a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are +ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads +only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom, +which becomes a fourth group once the tournament economy makes them sellable — and ordered by +ascending chip price within each group. Product titles are admin input, so the projection escapes +them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — diff --git a/ui/src/lib/offer.ts b/ui/src/lib/offer.ts index 4d0d969..396e4d3 100644 --- a/ui/src/lib/offer.ts +++ b/ui/src/lib/offer.ts @@ -31,6 +31,7 @@ export function renderOfferHtml(markdown: string): string { --text: #1a1c20; --accent: #2563eb; --rule: #e5e7eb; + --cell-border: #d1d5db; } @media (prefers-color-scheme: dark) { :root { @@ -38,6 +39,9 @@ export function renderOfferHtml(markdown: string): string { --text: #e7e9ee; --accent: #6ea8fe; --rule: #242832; + /* A muted grey — the section rules (--rule) vanish on the dark background, so table cells + get a slightly firmer border to stay legible without being loud. */ + --cell-border: #3a4250; } } * { @@ -77,6 +81,31 @@ export function renderOfferHtml(markdown: string): string { li { margin: 0.25em 0; } + /* The price tables (§4.4) span the full content width. */ + table { + width: 100%; + border-collapse: collapse; + margin: 0.75em 0 1.25em; + font-size: 0.95rem; + } + th, + td { + border: 1px solid var(--cell-border); + padding: 6px 10px; + } + /* The product-name column shrinks to its content and never wraps; the price columns share the + rest of the width (width:1% is the shrink-to-fit idiom paired with the table's width:100%). */ + th:first-child, + td:first-child { + width: 1%; + white-space: nowrap; + } + /* Right-aligned price columns. marked emits the alignment from the "---:" separator; this rule + keeps it applied whether that surfaces as an align attribute or an inline style. */ + th[align='right'], + td[align='right'] { + text-align: right; + } From 6e120bdaa7a166a4b30ba4c9cc67f40e45ecfc49 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 11:39:43 +0200 Subject: [PATCH 4/8] docs(offer): update phrasing --- ui/legal/offer_ru.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md index 2e2b866..2e1d3e3 100644 --- a/ui/legal/offer_ru.md +++ b/ui/legal/offer_ru.md @@ -20,6 +20,8 @@ **Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`. +**Приложение Прлодавца** - предоставляемое Продавцом для загрузки из сети «Интернет», добровольно загружаемое Покупателем и исполняемое на электронной вычислительной машине (устройстве) Покупателя программное обеспечение, предоставляющее игровые или иные функции и позволяющее Покупателю взимодействовать с Продавцом путём осуществления сделок купли-продажи внутригровых ценностей. Приложение может распространяться в форматах, но не ограничиваясь, такими как: веб-приложение на Сайте Продавца, мини-приложение в экосистеме VK, мини-приложение в экосистеме Telegram, Android-приложение в экосистеме Google Play, Android-приложение в экосистеме RuStore, iOS-приложение в экосистеме Apple App Store и дугих форматах распространения. + **Стороны Договора (Стороны)** — Продавец и Покупатель. **Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ. @@ -28,13 +30,13 @@ **2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму. -**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`. +**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на Сайте Продавца в сети «Интернет», либо в Приложении Продавца. **2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности: -- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи; +- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» либо в Приложении Продавца при наличии необходимости регистрации учетной записи; - путем составления и заполнения заявки на оформление заказа Товара; -- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя; +- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на Сайте Продавца в сети «Интернет» либо в Приложении Продавца, в том числе, при обратном звонке Продавца по заявке Покупателя; - оплаты Товара Покупателем. Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента. @@ -76,11 +78,11 @@ ## 4. Цена и порядок расчетов -**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`. +**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на Сайте Продавца в сети «Интернет» а так же в Приложении Продавца. **4.2.** Все расчеты по Договору производятся в безналичном порядке. -**4.3.** Порядок расчётов. Приобретаемым Товаром является внутриигровая валюта «Фишка» — условная учётная единица, используемая исключительно в пределах Сайта Продавца. «Фишки» предоставляют Покупателю возможность получения внутриигровых благ и дополнительных функций, включая, но не ограничиваясь: отказ от показа рекламы, приобретение подсказок в игре и иные внутриигровые возможности. «Фишка» не является электронным средством платежа либо денежным средством, не подлежит обмену на денежные средства и не может быть использована за пределами Сайта Продавца. «Фишки» зачисляются на внутриигровой счёт Покупателя единовременно после поступления оплаты; дальнейшее их использование для получения внутриигровых благ осуществляется Покупателем самостоятельно в интерфейсе игры. +**4.3.** Порядок расчётов. Приобретаемым Товаром является внутриигровая валюта «Фишка» — условная учётная единица, используемая исключительно в пределах Сайта Продавца либо в Приложения Продавца. «Фишки» предоставляют Покупателю возможность получения внутриигровых благ и дополнительных функций, включая, но не ограничиваясь: отказ от показа рекламы, приобретение подсказок в игре и иные внутриигровые возможности. «Фишка» не является электронным средством платежа либо денежным средством, не подлежит обмену на денежные средства и не может быть использована за пределами Сайта Продавца либо Приложения Продавца. «Фишки» зачисляются на внутриигровой счёт Покупателя единовременно после поступления оплаты; дальнейшее их использование для получения внутриигровых благ осуществляется Покупателем самостоятельно в рамках используемого Сайта Продавца либо Приложения Продавца. **4.4.** Стоимость Товаров: @@ -126,7 +128,7 @@ **9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору. -**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме при оплате Товаров. +**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на Сайте Продавца в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме при оплате Товаров. ## 10. Дополнительные условия @@ -144,7 +146,7 @@ **10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем. -**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок. +**10.6.** Если на Сайте Продавца в сети «Интернет» либо в Приложении Продавца есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок. ## 11. Реквизиты Продавца From b3cf024e9e680416d5c4a2ba30d4adcbf6f666ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 11:45:45 +0200 Subject: [PATCH 5/8] chore(offer): fix typo --- ui/legal/offer_ru.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md index 2e1d3e3..1763d65 100644 --- a/ui/legal/offer_ru.md +++ b/ui/legal/offer_ru.md @@ -20,7 +20,7 @@ **Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`. -**Приложение Прлодавца** - предоставляемое Продавцом для загрузки из сети «Интернет», добровольно загружаемое Покупателем и исполняемое на электронной вычислительной машине (устройстве) Покупателя программное обеспечение, предоставляющее игровые или иные функции и позволяющее Покупателю взимодействовать с Продавцом путём осуществления сделок купли-продажи внутригровых ценностей. Приложение может распространяться в форматах, но не ограничиваясь, такими как: веб-приложение на Сайте Продавца, мини-приложение в экосистеме VK, мини-приложение в экосистеме Telegram, Android-приложение в экосистеме Google Play, Android-приложение в экосистеме RuStore, iOS-приложение в экосистеме Apple App Store и дугих форматах распространения. +**Приложение Продавца** - предоставляемое Продавцом для загрузки из сети «Интернет», добровольно загружаемое Покупателем и исполняемое на электронной вычислительной машине (устройстве) Покупателя программное обеспечение, предоставляющее игровые или иные функции и позволяющее Покупателю взимодействовать с Продавцом путём осуществления сделок купли-продажи внутригровых ценностей. Приложение может распространяться в форматах, но не ограничиваясь, такими как: веб-приложение на Сайте Продавца, мини-приложение в экосистеме VK, мини-приложение в экосистеме Telegram, Android-приложение в экосистеме Google Play, Android-приложение в экосистеме RuStore, iOS-приложение в экосистеме Apple App Store и дугих форматах распространения. **Стороны Договора (Стороны)** — Продавец и Покупатель. From bb71e7b1c72588011b1ec6bee9f78c788125cf4f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 12:48:06 +0200 Subject: [PATCH 6/8] feat(bot): report Telegram bot Bot API health to the gateway over the bot-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot runs on its own host and exports no telemetry (otelcol is unreachable from there), so it was a monitoring blind spot. Observe its Bot API health centrally by wrapping the HTTP client — one place, no per-call-site instrumentation — and relay it up the existing bot-link as a periodic Health message the gateway turns into its own metrics. - proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to the FromBot oneof (additive, backward-compatible). - bot: platform/telegram/internal/health wraps the Bot API HTTP client — it stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even when idle), classifies transport/5xx failures (getUpdates vs other) and 429s, and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other than 429 is a normal per-request outcome and is not counted. - bot-link client: flush the reporter as a Health message every 30s over a single-sender loop (a gRPC stream forbids concurrent Send). - gateway: fold each report into bot_tg_errors_total{kind} and the bot_tg_last_ok_unix liveness gauge. - grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained 429s) routed to the operator email — which does not go through the bot — plus a Telegram-bot dashboard. - docs (ARCHITECTURE, compose comments); unit tests (observer classification, Retry-After, snapshot/commit, hub last-ok monotonicity). --- deploy/docker-compose.bot.yml | 9 +- deploy/grafana/dashboards/bot.json | 45 +++ .../grafana/provisioning/alerting/rules.yaml | 88 ++++++ docs/ARCHITECTURE.md | 18 +- gateway/internal/botlink/hub.go | 48 ++++ gateway/internal/botlink/hub_test.go | 18 ++ pkg/proto/botlink/v1/botlink.pb.go | 261 ++++++++++++------ pkg/proto/botlink/v1/botlink.proto | 18 +- platform/telegram/cmd/bot/main.go | 14 + platform/telegram/internal/bot/bot.go | 16 ++ platform/telegram/internal/botlink/client.go | 71 ++++- platform/telegram/internal/health/health.go | 145 ++++++++++ .../telegram/internal/health/health_test.go | 121 ++++++++ 13 files changed, 774 insertions(+), 98 deletions(-) create mode 100644 deploy/grafana/dashboards/bot.json create mode 100644 platform/telegram/internal/health/health.go create mode 100644 platform/telegram/internal/health/health_test.go diff --git a/deploy/docker-compose.bot.yml b/deploy/docker-compose.bot.yml index e3e9ca9..2231aba 100644 --- a/deploy/docker-compose.bot.yml +++ b/deploy/docker-compose.bot.yml @@ -3,8 +3,10 @@ # docker compose -f docker-compose.bot.yml up -d # # The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's -# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the -# main host and is unreachable from here — so observe it via `docker logs` on this host. +# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the +# main host and is unreachable from here — but it reports its Bot API health up the bot-link, +# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker +# logs` on this host is the local detail view. # Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the # pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's :9443. name: scrabble-bot @@ -50,7 +52,8 @@ services: TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info} TELEGRAM_SERVICE_NAME: scrabble-telegram-bot - # No telemetry export: otelcol is on the main host, unreachable from here. + # No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the + # bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md. TELEGRAM_OTEL_TRACES_EXPORTER: none TELEGRAM_OTEL_METRICS_EXPORTER: none GOMAXPROCS: "1" diff --git a/deploy/grafana/dashboards/bot.json b/deploy/grafana/dashboards/bot.json new file mode 100644 index 0000000..a8b4503 --- /dev/null +++ b/deploy/grafana/dashboards/bot.json @@ -0,0 +1,45 @@ +{ + "uid": "scrabble-bot", + "title": "Scrabble — Telegram bot", + "tags": ["scrabble"], + "timezone": "", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "panels": [ + { + "type": "stat", + "title": "Bot connected", + "description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).", + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }] + }, + { + "type": "stat", + "title": "Last Bot API OK (age)", + "description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.", + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }] + }, + { + "type": "timeseries", + "title": "Bot API errors/s by kind", + "description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }] + }, + { + "type": "timeseries", + "title": "Bot-link commands/s by result", + "description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }] + } + ] +} diff --git a/deploy/grafana/provisioning/alerting/rules.yaml b/deploy/grafana/provisioning/alerting/rules.yaml index 9657c23..fff25b2 100644 --- a/deploy/grafana/provisioning/alerting/rules.yaml +++ b/deploy/grafana/provisioning/alerting/rules.yaml @@ -277,3 +277,91 @@ groups: - evaluator: { type: gt, params: [1800] } labels: { severity: critical } annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' } + + # Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the + # gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over + # that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so + # noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so + # a bot-down alert is deliverable. + - orgId: 1 + name: scrabble-bot + folder: Alerts + interval: 1m + rules: + - uid: bot_disconnected + title: Telegram bot disconnected + condition: C + for: 5m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: { refId: A, expr: botlink_connected_bots, instant: true } + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: lt, params: [1] } + labels: { severity: critical } + annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' } + + # Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes + # old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the + # bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by + # bot_disconnected) does not double-fire. + - uid: bot_tg_stale + title: Telegram bot not reaching the Bot API + condition: C + for: 5m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [300] } + labels: { severity: critical } + annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' } + + # 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom + # (a send loop, a misbehaving path) worth investigating. + - uid: bot_tg_rate_limited + title: Telegram bot rate-limited (429) + condition: C + for: 5m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 900, to: 0 } + datasourceUid: prometheus + model: + refId: A + expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m]) + instant: true + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [0] } + labels: { severity: warning } + annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fbd26d4..7fc916f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1015,6 +1015,20 @@ answering `/start` with a URL button into the **main** bot's Mini App (`?startap button would sign initData with the promo token); it is self-contained — no bot-link, no gateway. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). +Because the bot **exports no telemetry of its own** (the OTel collector is on the main host, +unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic +`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the +getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful +Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place, +no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs +off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its +own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot +is thus monitored from the main host's Grafana with three layered signals: the **connection gauge** +(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a +silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and +**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to +the operator email, which does not depend on the bot. + A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an @@ -1073,7 +1087,9 @@ link — misses the event; while an add-email confirmation is pending the client `OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream and the gateway↔validator and bot-link calls; the gateway also exports - `botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus + `botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote + bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the + `bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana** (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth) are stood up with the deploy (`deploy/`); the default exporter stays diff --git a/gateway/internal/botlink/hub.go b/gateway/internal/botlink/hub.go index 91e9b3a..7851026 100644 --- a/gateway/internal/botlink/hub.go +++ b/gateway/internal/botlink/hub.go @@ -76,6 +76,11 @@ type Hub struct { connected metric.Int64UpDownCounter commands metric.Int64Counter + // tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind + // (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent + // successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert. + tgErrors metric.Int64Counter + lastOK atomic.Int64 } // link is one connected bot's outbound queue and identity. @@ -103,6 +108,19 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link.")) h.commands, _ = meter.Int64Counter("botlink_commands_total", metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error).")) + h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total", + metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited).")) + // The bot's last successful Bot API second, reported over the stream. An observable gauge + // reads the atomic on each collection; it observes nothing until a bot has reported, so a + // never-connected gateway shows no data (the connected-bots alert covers that case). + _, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix", + metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + if v := h.lastOK.Load(); v > 0 { + o.Observe(v) + } + return nil + })) } return h } @@ -149,6 +167,36 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1. if ack := msg.GetAck(); ack != nil { h.resolve(ack) } + if hb := msg.GetHealth(); hb != nil { + h.recordHealth(hb) + } + } +} + +// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three +// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock +// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it). +func (h *Hub) recordHealth(hb *botlinkv1.Health) { + if h.tgErrors != nil { + ctx := context.Background() + if v := hb.GetConnectFailures(); v > 0 { + h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect"))) + } + if v := hb.GetApiErrors(); v > 0 { + h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api"))) + } + if v := hb.GetRateLimited(); v > 0 { + h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited"))) + } + } + for { + cur := h.lastOK.Load() + if hb.GetLastOkUnix() <= cur { + break + } + if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) { + break + } } } diff --git a/gateway/internal/botlink/hub_test.go b/gateway/internal/botlink/hub_test.go index eae28fc..5fb4150 100644 --- a/gateway/internal/botlink/hub_test.go +++ b/gateway/internal/botlink/hub_test.go @@ -233,3 +233,21 @@ func TestRelayServerNoBot(t *testing.T) { t.Fatal("expected an error with no bot connected") } } + +// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but +// a stale or reordered report (an earlier second) never rewinds it. +func TestRecordHealthLastOKMonotonic(t *testing.T) { + hub := NewHub(nil, nil, nil) + hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100}) + if got := hub.lastOK.Load(); got != 100 { + t.Fatalf("lastOK = %d, want 100", got) + } + hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind + if got := hub.lastOK.Load(); got != 100 { + t.Errorf("lastOK rewound to %d, want 100", got) + } + hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150}) + if got := hub.lastOK.Load(); got != 150 { + t.Errorf("lastOK = %d, want 150", got) + } +} diff --git a/pkg/proto/botlink/v1/botlink.pb.go b/pkg/proto/botlink/v1/botlink.pb.go index 61d1c52..70b91a2 100644 --- a/pkg/proto/botlink/v1/botlink.pb.go +++ b/pkg/proto/botlink/v1/botlink.pb.go @@ -30,13 +30,14 @@ const ( ) // FromBot is a message the bot sends to the gateway: the opening Hello, then one -// Ack per Command. +// Ack per Command and a periodic Health report. type FromBot struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Msg: // // *FromBot_Hello // *FromBot_Ack + // *FromBot_Health Msg isFromBot_Msg `protobuf_oneof:"msg"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -97,6 +98,15 @@ func (x *FromBot) GetAck() *Ack { return nil } +func (x *FromBot) GetHealth() *Health { + if x != nil { + if x, ok := x.Msg.(*FromBot_Health); ok { + return x.Health + } + } + return nil +} + type isFromBot_Msg interface { isFromBot_Msg() } @@ -109,10 +119,92 @@ type FromBot_Ack struct { Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"` } +type FromBot_Health struct { + Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"` +} + func (*FromBot_Hello) isFromBot_Msg() {} func (*FromBot_Ack) isFromBot_Msg() {} +func (*FromBot_Health) isFromBot_Msg() {} + +// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote +// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives +// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three +// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a +// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock +// second of the bot's most recent successful Bot API response (any call, including the getUpdates +// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway +// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught. +type Health struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report + ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report + RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0) + LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Health) Reset() { + *x = Health{} + mi := &file_botlink_v1_botlink_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Health) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Health) ProtoMessage() {} + +func (x *Health) ProtoReflect() protoreflect.Message { + mi := &file_botlink_v1_botlink_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Health.ProtoReflect.Descriptor instead. +func (*Health) Descriptor() ([]byte, []int) { + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1} +} + +func (x *Health) GetConnectFailures() uint64 { + if x != nil { + return x.ConnectFailures + } + return 0 +} + +func (x *Health) GetApiErrors() uint64 { + if x != nil { + return x.ApiErrors + } + return 0 +} + +func (x *Health) GetRateLimited() uint64 { + if x != nil { + return x.RateLimited + } + return 0 +} + +func (x *Health) GetLastOkUnix() int64 { + if x != nil { + return x.LastOkUnix + } + return 0 +} + // ToBot is a message the gateway sends to the bot. Only Command is carried today. type ToBot struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -123,7 +215,7 @@ type ToBot struct { func (x *ToBot) Reset() { *x = ToBot{} - mi := &file_botlink_v1_botlink_proto_msgTypes[1] + mi := &file_botlink_v1_botlink_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -135,7 +227,7 @@ func (x *ToBot) String() string { func (*ToBot) ProtoMessage() {} func (x *ToBot) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[1] + mi := &file_botlink_v1_botlink_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -148,7 +240,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message { // Deprecated: Use ToBot.ProtoReflect.Descriptor instead. func (*ToBot) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2} } func (x *ToBot) GetCommand() *Command { @@ -171,7 +263,7 @@ type Hello struct { func (x *Hello) Reset() { *x = Hello{} - mi := &file_botlink_v1_botlink_proto_msgTypes[2] + mi := &file_botlink_v1_botlink_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -183,7 +275,7 @@ func (x *Hello) String() string { func (*Hello) ProtoMessage() {} func (x *Hello) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[2] + mi := &file_botlink_v1_botlink_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -196,7 +288,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message { // Deprecated: Use Hello.ProtoReflect.Descriptor instead. func (*Hello) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3} } func (x *Hello) GetInstanceId() string { @@ -233,7 +325,7 @@ type Command struct { func (x *Command) Reset() { *x = Command{} - mi := &file_botlink_v1_botlink_proto_msgTypes[3] + mi := &file_botlink_v1_botlink_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -245,7 +337,7 @@ func (x *Command) String() string { func (*Command) ProtoMessage() {} func (x *Command) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[3] + mi := &file_botlink_v1_botlink_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -258,7 +350,7 @@ func (x *Command) ProtoReflect() protoreflect.Message { // Deprecated: Use Command.ProtoReflect.Descriptor instead. func (*Command) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4} } func (x *Command) GetCommandId() string { @@ -372,7 +464,7 @@ type Ack struct { func (x *Ack) Reset() { *x = Ack{} - mi := &file_botlink_v1_botlink_proto_msgTypes[4] + mi := &file_botlink_v1_botlink_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -384,7 +476,7 @@ func (x *Ack) String() string { func (*Ack) ProtoMessage() {} func (x *Ack) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[4] + mi := &file_botlink_v1_botlink_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -397,7 +489,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message { // Deprecated: Use Ack.ProtoReflect.Descriptor instead. func (*Ack) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5} } func (x *Ack) GetCommandId() string { @@ -445,7 +537,7 @@ type ChatGateCommand struct { func (x *ChatGateCommand) Reset() { *x = ChatGateCommand{} - mi := &file_botlink_v1_botlink_proto_msgTypes[5] + mi := &file_botlink_v1_botlink_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -457,7 +549,7 @@ func (x *ChatGateCommand) String() string { func (*ChatGateCommand) ProtoMessage() {} func (x *ChatGateCommand) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[5] + mi := &file_botlink_v1_botlink_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -470,7 +562,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead. func (*ChatGateCommand) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6} } func (x *ChatGateCommand) GetExternalId() string { @@ -498,7 +590,7 @@ type ChatEligibilityRequest struct { func (x *ChatEligibilityRequest) Reset() { *x = ChatEligibilityRequest{} - mi := &file_botlink_v1_botlink_proto_msgTypes[6] + mi := &file_botlink_v1_botlink_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -510,7 +602,7 @@ func (x *ChatEligibilityRequest) String() string { func (*ChatEligibilityRequest) ProtoMessage() {} func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[6] + mi := &file_botlink_v1_botlink_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -523,7 +615,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead. func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7} } func (x *ChatEligibilityRequest) GetExternalId() string { @@ -546,7 +638,7 @@ type ChatEligibilityResponse struct { func (x *ChatEligibilityResponse) Reset() { *x = ChatEligibilityResponse{} - mi := &file_botlink_v1_botlink_proto_msgTypes[7] + mi := &file_botlink_v1_botlink_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -558,7 +650,7 @@ func (x *ChatEligibilityResponse) String() string { func (*ChatEligibilityResponse) ProtoMessage() {} func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[7] + mi := &file_botlink_v1_botlink_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -571,7 +663,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead. func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8} } func (x *ChatEligibilityResponse) GetRegistered() bool { @@ -605,7 +697,7 @@ type CreateInvoiceCommand struct { func (x *CreateInvoiceCommand) Reset() { *x = CreateInvoiceCommand{} - mi := &file_botlink_v1_botlink_proto_msgTypes[8] + mi := &file_botlink_v1_botlink_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -617,7 +709,7 @@ func (x *CreateInvoiceCommand) String() string { func (*CreateInvoiceCommand) ProtoMessage() {} func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[8] + mi := &file_botlink_v1_botlink_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -630,7 +722,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead. func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9} } func (x *CreateInvoiceCommand) GetTitle() string { @@ -674,7 +766,7 @@ type PreCheckoutRequest struct { func (x *PreCheckoutRequest) Reset() { *x = PreCheckoutRequest{} - mi := &file_botlink_v1_botlink_proto_msgTypes[9] + mi := &file_botlink_v1_botlink_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -686,7 +778,7 @@ func (x *PreCheckoutRequest) String() string { func (*PreCheckoutRequest) ProtoMessage() {} func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[9] + mi := &file_botlink_v1_botlink_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -699,7 +791,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead. func (*PreCheckoutRequest) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10} } func (x *PreCheckoutRequest) GetOrderId() string { @@ -735,7 +827,7 @@ type PreCheckoutResponse struct { func (x *PreCheckoutResponse) Reset() { *x = PreCheckoutResponse{} - mi := &file_botlink_v1_botlink_proto_msgTypes[10] + mi := &file_botlink_v1_botlink_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -747,7 +839,7 @@ func (x *PreCheckoutResponse) String() string { func (*PreCheckoutResponse) ProtoMessage() {} func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[10] + mi := &file_botlink_v1_botlink_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -760,7 +852,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead. func (*PreCheckoutResponse) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11} } func (x *PreCheckoutResponse) GetOk() bool { @@ -792,7 +884,7 @@ type ForwardPaymentRequest struct { func (x *ForwardPaymentRequest) Reset() { *x = ForwardPaymentRequest{} - mi := &file_botlink_v1_botlink_proto_msgTypes[11] + mi := &file_botlink_v1_botlink_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -804,7 +896,7 @@ func (x *ForwardPaymentRequest) String() string { func (*ForwardPaymentRequest) ProtoMessage() {} func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[11] + mi := &file_botlink_v1_botlink_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -817,7 +909,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead. func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12} } func (x *ForwardPaymentRequest) GetOrderId() string { @@ -861,7 +953,7 @@ type ForwardPaymentResponse struct { func (x *ForwardPaymentResponse) Reset() { *x = ForwardPaymentResponse{} - mi := &file_botlink_v1_botlink_proto_msgTypes[12] + mi := &file_botlink_v1_botlink_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -873,7 +965,7 @@ func (x *ForwardPaymentResponse) String() string { func (*ForwardPaymentResponse) ProtoMessage() {} func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message { - mi := &file_botlink_v1_botlink_proto_msgTypes[12] + mi := &file_botlink_v1_botlink_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -886,7 +978,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead. func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) { - return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12} + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13} } func (x *ForwardPaymentResponse) GetCredited() bool { @@ -900,11 +992,19 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor const file_botlink_v1_botlink_proto_rawDesc = "" + "\n" + - "\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" + + "\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" + "\aFromBot\x122\n" + "\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" + - "\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" + - "\x03msg\"?\n" + + "\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" + + "\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" + + "\x03msg\"\x97\x01\n" + + "\x06Health\x12)\n" + + "\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" + + "\n" + + "api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" + + "\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" + + "\flast_ok_unix\x18\x04 \x01(\x03R\n" + + "lastOkUnix\"?\n" + "\x05ToBot\x126\n" + "\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" + "\x05Hello\x12\x1f\n" + @@ -976,47 +1076,49 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte { return file_botlink_v1_botlink_proto_rawDescData } -var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_botlink_v1_botlink_proto_goTypes = []any{ (*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot - (*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot - (*Hello)(nil), // 2: scrabble.botlink.v1.Hello - (*Command)(nil), // 3: scrabble.botlink.v1.Command - (*Ack)(nil), // 4: scrabble.botlink.v1.Ack - (*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand - (*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest - (*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse - (*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand - (*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest - (*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse - (*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest - (*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse - (*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest - (*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest - (*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest + (*Health)(nil), // 1: scrabble.botlink.v1.Health + (*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot + (*Hello)(nil), // 3: scrabble.botlink.v1.Hello + (*Command)(nil), // 4: scrabble.botlink.v1.Command + (*Ack)(nil), // 5: scrabble.botlink.v1.Ack + (*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand + (*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest + (*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse + (*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand + (*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest + (*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse + (*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest + (*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse + (*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest + (*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest + (*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest } var file_botlink_v1_botlink_proto_depIdxs = []int32{ - 2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello - 4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack - 3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command - 13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest - 14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest - 15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest - 5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand - 8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand - 0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot - 6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest - 9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest - 11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest - 1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot - 7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse - 10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse - 12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse - 12, // [12:16] is the sub-list for method output_type - 8, // [8:12] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello + 5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack + 1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health + 4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command + 14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest + 15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest + 16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest + 6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand + 9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand + 0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot + 7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest + 10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest + 12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest + 2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot + 8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse + 11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse + 13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse + 13, // [13:17] is the sub-list for method output_type + 9, // [9:13] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_botlink_v1_botlink_proto_init() } @@ -1027,8 +1129,9 @@ func file_botlink_v1_botlink_proto_init() { file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{ (*FromBot_Hello)(nil), (*FromBot_Ack)(nil), + (*FromBot_Health)(nil), } - file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{ + file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{ (*Command_Notify)(nil), (*Command_SendToUser)(nil), (*Command_SendToChannel)(nil), @@ -1041,7 +1144,7 @@ func file_botlink_v1_botlink_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)), NumEnums: 0, - NumMessages: 13, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/botlink/v1/botlink.proto b/pkg/proto/botlink/v1/botlink.proto index 3f37723..b08ddf4 100644 --- a/pkg/proto/botlink/v1/botlink.proto +++ b/pkg/proto/botlink/v1/botlink.proto @@ -48,14 +48,30 @@ service BotLink { } // FromBot is a message the bot sends to the gateway: the opening Hello, then one -// Ack per Command. +// Ack per Command and a periodic Health report. message FromBot { oneof msg { Hello hello = 1; Ack ack = 2; + Health health = 3; } } +// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote +// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives +// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three +// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a +// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock +// second of the bot's most recent successful Bot API response (any call, including the getUpdates +// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway +// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught. +message Health { + uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report + uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report + uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0) + int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet) +} + // ToBot is a message the gateway sends to the bot. Only Command is carried today. message ToBot { Command command = 1; diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 152fad7..51eb62d 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -23,6 +23,7 @@ import ( "scrabble/platform/telegram/internal/bot" "scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/config" + "scrabble/platform/telegram/internal/health" "scrabble/platform/telegram/internal/outbox" "scrabble/platform/telegram/internal/promobot" "scrabble/platform/telegram/internal/support" @@ -31,6 +32,10 @@ import ( // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. const telemetryShutdownTimeout = 5 * time.Second +// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway +// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram. +const healthReportInterval = 30 * time.Second + func main() { cfg, err := config.LoadBot() if err != nil { @@ -81,6 +86,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { } } + // The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here), + // so its Bot API health is observed centrally and reported to the gateway over the bot-link, + // which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link + // client (which flushes it). + healthReporter := health.NewReporter() + b, err := bot.New(bot.Config{ Token: cfg.Token, APIBaseURL: cfg.APIBaseURL, @@ -92,6 +103,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { SupportChatID: cfg.SupportChatID, SupportStore: supportStore, AcceptPayments: cfg.StarsOutboxDir != "", + Health: healthReporter, }, logger) if err != nil { return err @@ -120,6 +132,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { OwnsUpdates: cfg.OwnsUpdates, Creds: credentials.NewTLS(tlsCfg), ReconnectDelay: cfg.BotLink.ReconnectDelay, + Health: healthReporter, + HealthInterval: healthReportInterval, }, exec, logger) if err != nil { return err diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 0ca666b..982e3f5 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -7,19 +7,26 @@ package bot import ( "context" + "net/http" "net/url" "strconv" "strings" + "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "go.uber.org/zap" "golang.org/x/time/rate" + "scrabble/platform/telegram/internal/health" "scrabble/platform/telegram/internal/outbox" "scrabble/platform/telegram/internal/support" ) +// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set +// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout. +const botPollTimeout = time.Minute + // Config configures the bot wrapper. type Config struct { // Token is the Bot API token. @@ -54,6 +61,9 @@ type Config struct { // pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime // dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers. AcceptPayments bool + // Health, when set, observes every Bot API request for health metrics (reported to the gateway + // over the bot-link) and honours a 429's Retry-After. Nil disables the observation. + Health *health.Reporter } // EligibilityResolver answers whether the Telegram user identified by externalID @@ -159,6 +169,12 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { if cfg.APIBaseURL != "" { opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL)) } + if cfg.Health != nil { + // Observe every Bot API request centrally for health metrics and the 429 back-off. The + // client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side). + base := &http.Client{Timeout: botPollTimeout + 10*time.Second} + opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base))) + } api, err := tgbot.New(cfg.Token, opts...) if err != nil { return nil, err diff --git a/platform/telegram/internal/botlink/client.go b/platform/telegram/internal/botlink/client.go index 92fa84c..4e2ed5f 100644 --- a/platform/telegram/internal/botlink/client.go +++ b/platform/telegram/internal/botlink/client.go @@ -11,6 +11,7 @@ import ( "google.golang.org/grpc/keepalive" botlinkv1 "scrabble/pkg/proto/botlink/v1" + "scrabble/platform/telegram/internal/health" ) const ( @@ -34,6 +35,11 @@ type ClientConfig struct { Creds credentials.TransportCredentials // ReconnectDelay is the pause before re-dialing after the stream ends. ReconnectDelay time.Duration + // Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health + // message every HealthInterval while the stream is open. Nil disables health reporting. + Health *health.Reporter + // HealthInterval is the cadence of the Health flush; 0 disables it. + HealthInterval time.Duration } // Client maintains the long-lived bot-link to the gateway, executing the commands @@ -140,22 +146,59 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro } c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates)) + // One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every + // stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send. + rctx, cancel := context.WithCancel(ctx) + defer cancel() + cmds := make(chan *botlinkv1.Command) + recvErr := make(chan error, 1) + go func() { + for { + msg, err := stream.Recv() + if err != nil { + recvErr <- err + return + } + if cmd := msg.GetCommand(); cmd != nil { + select { + case cmds <- cmd: + case <-rctx.Done(): + return + } + } + } + }() + + var healthTick <-chan time.Time + if c.cfg.Health != nil && c.cfg.HealthInterval > 0 { + t := time.NewTicker(c.cfg.HealthInterval) + defer t.Stop() + healthTick = t.C + } + for { - msg, err := stream.Recv() - if err != nil { - return err - } - cmd := msg.GetCommand() - if cmd == nil { - continue - } - delivered, result, herr := c.exec.Handle(ctx, cmd) - ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result} - if herr != nil { - ack.Error = herr.Error() - } - if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-recvErr: return err + case cmd := <-cmds: + delivered, result, herr := c.exec.Handle(ctx, cmd) + ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result} + if herr != nil { + ack.Error = herr.Error() + } + if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil { + return err + } + case <-healthTick: + // Snapshot without resetting; only commit (clear the deltas) after a successful send, so a + // failed flush loses nothing and the counts ride the next connection. + hb := c.cfg.Health.Snapshot() + if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil { + return err + } + c.cfg.Health.Commit(hb) } } } diff --git a/platform/telegram/internal/health/health.go b/platform/telegram/internal/health/health.go new file mode 100644 index 0000000..b8351fb --- /dev/null +++ b/platform/telegram/internal/health/health.go @@ -0,0 +1,145 @@ +// Package health tracks the Telegram bot's Bot API health and reports it to the gateway over the +// bot-link. The bot exports no telemetry of its own — otelcol lives on the main host, unreachable +// from the bot host — so the gateway turns these reports into its own metrics (see +// gateway/internal/botlink). Health is observed CENTRALLY by wrapping the Bot API HTTP client +// ([Reporter.Wrap]); every request is classified there with no per-call-site instrumentation. +package health + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" + + botlinkv1 "scrabble/pkg/proto/botlink/v1" +) + +// maxBackoff caps how long a single 429 makes the bot wait, so a hostile or buggy Retry-After can +// never wedge the bot. +const maxBackoff = 30 * time.Second + +// Reporter accumulates the bot's Bot API health between reports: three delta counters and the last +// successful-response stamp. It is safe for concurrent use. The bot-link client periodically +// [Reporter.Snapshot]s it, sends the snapshot as a botlink Health, and [Reporter.Commit]s it on a +// successful send. +type Reporter struct { + connectFailures atomic.Int64 + apiErrors atomic.Int64 + rateLimited atomic.Int64 + lastOKUnix atomic.Int64 +} + +// NewReporter returns an empty Reporter. +func NewReporter() *Reporter { return &Reporter{} } + +// Snapshot reads the current delta counters and the last-ok stamp into a Health message WITHOUT +// resetting them. The counters are cleared only by [Reporter.Commit] after a successful send, so a +// failed send loses nothing. +func (r *Reporter) Snapshot() *botlinkv1.Health { + return &botlinkv1.Health{ + ConnectFailures: uint64(max(r.connectFailures.Load(), 0)), + ApiErrors: uint64(max(r.apiErrors.Load(), 0)), + RateLimited: uint64(max(r.rateLimited.Load(), 0)), + LastOkUnix: r.lastOKUnix.Load(), + } +} + +// Commit subtracts a just-sent snapshot's delta counters, so counts accrued between the snapshot and +// the send survive into the next report. last_ok is a stamp, not a delta, so it is left as is. +func (r *Reporter) Commit(sent *botlinkv1.Health) { + r.connectFailures.Add(-int64(sent.GetConnectFailures())) + r.apiErrors.Add(-int64(sent.GetApiErrors())) + r.rateLimited.Add(-int64(sent.GetRateLimited())) +} + +// Wrap returns an http.Client-shaped observer over base for tgbot.WithHTTPClient: it stamps liveness +// on every successful response, counts transport and 5xx failures (split getUpdates vs other) and +// 429s, and honours a 429's Retry-After (bounded by maxBackoff) so the bot backs off instead of +// hammering. A 4xx other than 429 (a user blocked the bot, a malformed request) is a normal +// per-request outcome, not a health signal, and is not counted. +func (r *Reporter) Wrap(base Doer) Doer { return &observer{base: base, r: r} } + +// Doer is the minimal HTTP surface the Bot API client needs; both *http.Client and [observer] +// satisfy it, and it matches the go-telegram/bot HttpClient interface. +type Doer interface { + Do(*http.Request) (*http.Response, error) +} + +// observer is the Bot API HTTP client wrapper (see [Reporter.Wrap]). +type observer struct { + base Doer + r *Reporter +} + +// Do performs the request and records its health outcome. +func (o *observer) Do(req *http.Request) (*http.Response, error) { + resp, err := o.base.Do(req) + poll := strings.HasSuffix(req.URL.Path, "/getUpdates") + if err != nil { + // A cancelled context is a shutdown, not a Bot API failure. + if req.Context().Err() == nil { + o.fail(poll) + } + return resp, err + } + switch { + case resp.StatusCode == http.StatusTooManyRequests: + o.r.rateLimited.Add(1) + o.backoff(req.Context(), resp) + case resp.StatusCode >= 200 && resp.StatusCode < 300: + o.r.lastOKUnix.Store(time.Now().Unix()) + case resp.StatusCode >= 500: + o.fail(poll) + } + return resp, err +} + +// fail records a connect failure on the getUpdates long-poll or an api error otherwise. +func (o *observer) fail(poll bool) { + if poll { + o.r.connectFailures.Add(1) + } else { + o.r.apiErrors.Add(1) + } +} + +// backoff waits out a 429's Retry-After (bounded, cancellable). It buffers the response body first so +// the wait holds no connection open and the Bot API library can still parse the 429 afterwards. +func (o *observer) backoff(ctx context.Context, resp *http.Response) { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + d := retryAfter(resp.Header.Get("Retry-After"), body) + if d <= 0 { + return + } + if d > maxBackoff { + d = maxBackoff + } + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +// retryAfter resolves the 429 back-off from the Retry-After header, falling back to the Bot API +// body's parameters.retry_after (both are seconds). It returns 0 when neither gives a positive value. +func retryAfter(header string, body []byte) time.Duration { + if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 { + return time.Duration(secs) * time.Second + } + var payload struct { + Parameters struct { + RetryAfter int `json:"retry_after"` + } `json:"parameters"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Parameters.RetryAfter > 0 { + return time.Duration(payload.Parameters.RetryAfter) * time.Second + } + return 0 +} diff --git a/platform/telegram/internal/health/health_test.go b/platform/telegram/internal/health/health_test.go new file mode 100644 index 0000000..82e67fb --- /dev/null +++ b/platform/telegram/internal/health/health_test.go @@ -0,0 +1,121 @@ +package health + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeDoer returns a canned response and error for every request. +type fakeDoer struct { + resp *http.Response + err error +} + +func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { return f.resp, f.err } + +// mkResp builds a response with a status, optional Retry-After header and body. +func mkResp(status int, retryAfter, body string) *http.Response { + h := http.Header{} + if retryAfter != "" { + h.Set("Retry-After", retryAfter) + } + return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body))} +} + +// run sends one request for the Bot API method through a fresh observer and returns the reporter's +// resulting snapshot. The 429 cases carry no Retry-After, so the back-off returns before any wait. +func run(method string, resp *http.Response, err error) *Reporter { + r := NewReporter() + req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/"+method, nil) + _, _ = r.Wrap(&fakeDoer{resp: resp, err: err}).Do(req) + return r +} + +func TestObserverClassifies(t *testing.T) { + cases := []struct { + name string + method string + resp *http.Response + err error + wantConnect uint64 + wantAPI uint64 + wantRate uint64 + wantLastOKSet bool + }{ + {"poll ok stamps liveness", "getUpdates", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true}, + {"send ok stamps liveness", "sendMessage", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true}, + {"poll 5xx is a connect failure", "getUpdates", mkResp(502, "", ""), nil, 1, 0, 0, false}, + {"send 5xx is an api error", "sendMessage", mkResp(500, "", ""), nil, 0, 1, 0, false}, + {"429 is rate limited, not an error", "sendMessage", mkResp(429, "", `{"ok":false}`), nil, 0, 0, 1, false}, + {"4xx other than 429 is not counted", "sendMessage", mkResp(403, "", ""), nil, 0, 0, 0, false}, + {"poll transport error is a connect failure", "getUpdates", nil, errors.New("dial"), 1, 0, 0, false}, + {"send transport error is an api error", "sendMessage", nil, errors.New("dial"), 0, 1, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := run(tc.method, tc.resp, tc.err).Snapshot() + if s.GetConnectFailures() != tc.wantConnect || s.GetApiErrors() != tc.wantAPI || s.GetRateLimited() != tc.wantRate { + t.Errorf("counters = connect %d api %d rate %d, want %d/%d/%d", + s.GetConnectFailures(), s.GetApiErrors(), s.GetRateLimited(), tc.wantConnect, tc.wantAPI, tc.wantRate) + } + if (s.GetLastOkUnix() > 0) != tc.wantLastOKSet { + t.Errorf("last_ok set = %v, want %v", s.GetLastOkUnix() > 0, tc.wantLastOKSet) + } + }) + } +} + +// TestObserverIgnoresShutdownTransportError checks a transport error under a cancelled context (a +// shutdown) is not counted as a Bot API failure. +func TestObserverIgnoresShutdownTransportError(t *testing.T) { + r := NewReporter() + req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/getUpdates", nil) + ctx, cancel := context.WithCancel(req.Context()) + cancel() + _, _ = r.Wrap(&fakeDoer{err: errors.New("dial")}).Do(req.WithContext(ctx)) + if s := r.Snapshot(); s.GetConnectFailures() != 0 { + t.Errorf("shutdown transport error must not count, got connect=%d", s.GetConnectFailures()) + } +} + +func TestRetryAfter(t *testing.T) { + cases := []struct { + header string + body string + want time.Duration + }{ + {"5", "", 5 * time.Second}, + {"", `{"parameters":{"retry_after":7}}`, 7 * time.Second}, + {"3", `{"parameters":{"retry_after":9}}`, 3 * time.Second}, // header wins + {"", `{"ok":false}`, 0}, + {"0", "", 0}, + {"", "not json", 0}, + } + for _, tc := range cases { + if got := retryAfter(tc.header, []byte(tc.body)); got != tc.want { + t.Errorf("retryAfter(%q, %q) = %v, want %v", tc.header, tc.body, got, tc.want) + } + } +} + +// TestSnapshotCommit checks Commit clears only the sent deltas, so counts accrued between the +// snapshot and the send survive into the next report. +func TestSnapshotCommit(t *testing.T) { + r := NewReporter() + r.apiErrors.Add(3) + snap := r.Snapshot() + if snap.GetApiErrors() != 3 { + t.Fatalf("snapshot api_errors = %d, want 3", snap.GetApiErrors()) + } + r.apiErrors.Add(2) // accrues after the snapshot + r.Commit(snap) + if got := r.Snapshot().GetApiErrors(); got != 2 { + t.Errorf("after commit api_errors = %d, want 2 (the post-snapshot accrual)", got) + } +} From 2c2316fb5e7f23f6521d8ab57058df6c8ca1389d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 12:53:37 +0200 Subject: [PATCH 7/8] chore(catalog): order the admin catalog list like the public offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sales (chip packs) first, ascending by rouble price; then the chip-exchange values, grouped and price-sorted the same way projectOfferPricing lists them, so the /catalog console mirrors what a buyer sees. Internal cosmetics only — no product behaviour change. The value-group order moves to a shared helper (valueGroup) so the offer and the admin list cannot drift. --- backend/internal/payments/catalog_admin.go | 62 +++++++++++++++++++ backend/internal/payments/catalog_test.go | 44 +++++++++++++ backend/internal/payments/offer.go | 16 +++-- .../internal/server/handlers_admin_catalog.go | 3 + 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index 401ec98..e2d351c 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -1,9 +1,12 @@ package payments import ( + "cmp" "context" "errors" "fmt" + "math" + "slices" "strings" "github.com/google/uuid" @@ -147,6 +150,65 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { return s.store.adminCatalog(ctx) } +// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products +// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values, +// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a +// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived +// products the same as active ones (the admin list shows both). +func SortAdminCatalog(products []AdminProduct) { + slices.SortStableFunc(products, func(a, b AdminProduct) int { + if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb { + if pa { + return -1 // packs (sales) before values (chip exchange) + } + return 1 + } else if pa { + return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB)) + } + if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 { + return d + } + return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip)) + }) +} + +// adminIsPack reports whether the product is a chip pack (it carries the chips atom). +func adminIsPack(p AdminProduct) bool { + for _, a := range p.Atoms { + if a.Atom == atomChips { + return true + } + } + return false +} + +// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]). +func adminValueGroup(p AdminProduct) int { + hasHints, hasNoAds, hasTournament := false, false, false + for _, a := range p.Atoms { + switch a.Atom { + case "hints": + hasHints = true + case "noads_days": + hasNoAds = true + case "tournament": + hasTournament = true + } + } + return valueGroup(hasHints, hasNoAds, hasTournament) +} + +// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency, +// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first). +func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 { + for _, pr := range p.Prices { + if pr.Method == method && pr.Currency == cur { + return pr.Amount + } + } + return math.MaxInt64 +} + // CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A // product created active must satisfy the sellable shape. func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) { diff --git a/backend/internal/payments/catalog_test.go b/backend/internal/payments/catalog_test.go index a243a98..132fa9a 100644 --- a/backend/internal/payments/catalog_test.go +++ b/backend/internal/payments/catalog_test.go @@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) { t.Errorf("empty catalog projected %d products, want 0", len(got.Products)) } } + +// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first, +// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and +// ascending by chip price within a group. Stable and applied to active + archived alike. +func TestSortAdminCatalog(t *testing.T) { + pack := func(title string, rub int64) AdminProduct { + return AdminProduct{ + Title: title, + Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}}, + Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}}, + } + } + value := func(title string, chips int64, atoms ...string) AdminProduct { + p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}} + for _, a := range atoms { + p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1}) + } + return p + } + products := []AdminProduct{ + pack("packDear", 30000), + value("bundle", 500, "hints", "noads_days"), + pack("packCheap", 10000), + value("adsOnly", 150, "noads_days"), + value("hintsBig", 200, "hints"), + value("hintsSmall", 50, "hints"), + } + SortAdminCatalog(products) + want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"} + for i, p := range products { + if p.Title != want[i] { + t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products)) + } + } +} + +// titles extracts the product titles for a failure message. +func titles(products []AdminProduct) []string { + out := make([]string, len(products)) + for i, p := range products { + out[i] = p.Title + } + return out +} diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index 0fc4cc0..f4c6392 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -109,11 +109,7 @@ func projectOfferPricing(entries []catalogEntry) string { return strings.TrimRight(b.String(), "\n") } -// offerValueGroup ranks a chip-priced value into the offer's usage groups, in listing order: hints -// only (0), no-ads only (1), no-ads + hints (2), then anything carrying the tournament atom (3). -// Tournament products are not sellable yet (validateProduct forbids an active one), so group 3 is -// empty today; the rank reserves their place for when the tournament economy lands. A value with no -// recognised benefit atom sorts after the known groups (defensive — the catalog shape forbids it). +// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]). func offerValueGroup(e catalogEntry) int { hasHints, hasNoAds, hasTournament := false, false, false for _, a := range e.atoms { @@ -126,6 +122,16 @@ func offerValueGroup(e catalogEntry) int { hasTournament = true } } + return valueGroup(hasHints, hasNoAds, hasTournament) +} + +// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the +// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything +// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids +// an active one), so group 3 is empty today; the rank reserves their place for when the tournament +// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive — +// the catalog shape forbids it). +func valueGroup(hasHints, hasNoAds, hasTournament bool) int { switch { case hasTournament: return 3 diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index aca135b..9912d08 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) { s.consoleError(c, err) return } + // Order the list like the public offer: sales (chip packs) first, then the chip-exchange values, + // grouped and price-sorted the same way, so the console mirrors what a buyer sees. + payments.SortAdminCatalog(products) var view adminconsole.CatalogView for _, p := range products { view.Products = append(view.Products, catalogRow(p)) From 4ba9da6721e9e71ff71d55d8ce6509cbe9dd862e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 13:33:28 +0200 Subject: [PATCH 8/8] feat(edge): community IP blocklist (Spamhaus DROP) at the gateway Refuse a client whose IP is in a curated CIDR feed with 403 in the same abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by default. - ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the last-good feed on a transient fetch failure and drops it fail-open once stale (better to under-block than block a legitimate client on a frozen feed). A separate static CIDR set, not the per-IP fail2ban store. - gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}. IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot). - observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana alert warns before the feed is dropped; service-overview panels. - deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in via PROD_ vars). - docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip, parser, fault-tolerance) + a 403 abuse-guard integration test. --- .gitea/workflows/prod-deploy.yaml | 5 + .gitea/workflows/prod-rollback.yaml | 4 + deploy/README.md | 3 + deploy/docker-compose.yml | 6 + .../grafana/dashboards/service-overview.json | 25 +++ .../grafana/provisioning/alerting/rules.yaml | 26 +++ deploy/write-prod-env.sh | 5 + docs/ARCHITECTURE.md | 12 ++ gateway/cmd/gateway/blocklist.go | 86 ++++++++ gateway/cmd/gateway/main.go | 10 + gateway/internal/config/config.go | 69 +++++++ gateway/internal/connectsrv/abuse_test.go | 38 +++- gateway/internal/connectsrv/metrics.go | 31 ++- gateway/internal/connectsrv/metrics_test.go | 8 +- gateway/internal/connectsrv/server.go | 18 +- gateway/internal/ratelimit/blocklist.go | 188 ++++++++++++++++++ gateway/internal/ratelimit/blocklist_test.go | 106 ++++++++++ 17 files changed, 627 insertions(+), 13 deletions(-) create mode 100644 gateway/cmd/gateway/blocklist.go create mode 100644 gateway/internal/ratelimit/blocklist.go create mode 100644 gateway/internal/ratelimit/blocklist_test.go diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index a9038de..af01215 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -115,6 +115,11 @@ jobs: # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} + # Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once + # verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off. + GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }} + GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }} + GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }} # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} # Transactional email via the shared Selectel relay (confirm-codes): one account for diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml index c461093..c4f4134 100644 --- a/.gitea/workflows/prod-rollback.yaml +++ b/.gitea/workflows/prod-rollback.yaml @@ -70,6 +70,10 @@ jobs: VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} + # Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy. + GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }} + GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }} + GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} diff --git a/deploy/README.md b/deploy/README.md index 60e03af..07833c1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -115,6 +115,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. | | `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. | | `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. | +| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. | +| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. | +| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cb61d2b..989ffc0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -254,6 +254,12 @@ services: # secret; empty (unset secret) leaves the trap off. GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} + # Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible + # (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the + # prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults. + GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false} + GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-} + GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_OTEL_TRACES_EXPORTER: otlp diff --git a/deploy/grafana/dashboards/service-overview.json b/deploy/grafana/dashboards/service-overview.json index 0f91b30..9276661 100644 --- a/deploy/grafana/dashboards/service-overview.json +++ b/deploy/grafana/dashboards/service-overview.json @@ -53,6 +53,31 @@ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }] + }, + { + "type": "stat", + "title": "Blocklist entries", + "description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).", + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }] + }, + { + "type": "stat", + "title": "Blocklist feed age", + "description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.", + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }] + }, + { + "type": "timeseries", + "title": "Blocklist blocks/s", + "description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }] } ] } diff --git a/deploy/grafana/provisioning/alerting/rules.yaml b/deploy/grafana/provisioning/alerting/rules.yaml index fff25b2..b53dc5c 100644 --- a/deploy/grafana/provisioning/alerting/rules.yaml +++ b/deploy/grafana/provisioning/alerting/rules.yaml @@ -108,6 +108,32 @@ groups: labels: { severity: critical } annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' } + # The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every + # few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled + # or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its + # max-staleness window; this warns well before that so the operator can fix the fetch. + - uid: blocklist_stale + title: IP blocklist feed not refreshing + condition: C + for: 15m + noDataState: OK + execErrState: OK + data: + - refId: A + relativeTimeRange: { from: 300, to: 0 } + datasourceUid: prometheus + model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true } + - refId: C + datasourceUid: __expr__ + model: + refId: C + type: threshold + expression: A + conditions: + - evaluator: { type: gt, params: [86400] } + labels: { severity: warning } + annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' } + - orgId: 1 name: scrabble-host folder: Alerts diff --git a/deploy/write-prod-env.sh b/deploy/write-prod-env.sh index cd66903..7c15141 100755 --- a/deploy/write-prod-env.sh +++ b/deploy/write-prod-env.sh @@ -77,6 +77,11 @@ export GF_SMTP_ENABLED='$GF_SMTP_ENABLED' export PUBLIC_BASE_URL='$PUBLIC_BASE_URL' export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN' export GATEWAY_ABUSE_BAN_ENABLED='true' +# Community IP blocklist (Spamhaus DROP): opt-in — the operator enables it and sets the feed URL + +# allowlist via PROD_GATEWAY_BLOCKLIST_* vars once the feed is verified. Unset ⇒ off (safe). +export GATEWAY_BLOCKLIST_ENABLED='${GATEWAY_BLOCKLIST_ENABLED:-false}' +export GATEWAY_BLOCKLIST_URL='$GATEWAY_BLOCKLIST_URL' +export GATEWAY_BLOCKLIST_ALLOW='$GATEWAY_BLOCKLIST_ALLOW' # Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact # ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the # operator sets the S3 repository values + secrets, creates the stanza and flips the switch diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7fc916f..75723cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1206,6 +1206,18 @@ link — misses the event; while an add-email confirmation is pending the client (`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies the operator unbans the response returns, so a manual unban takes effect within the sync interval. +- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also + refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with + **403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the + feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`, + binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and + fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than + `GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a + frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by + default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static + CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries). + Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the + `gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped). - Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the database answers a bounded ping and the session cache is warmed). - The backend serves a **second listener** — a gRPC server diff --git a/gateway/cmd/gateway/blocklist.go b/gateway/cmd/gateway/blocklist.go new file mode 100644 index 0000000..c4e37a9 --- /dev/null +++ b/gateway/cmd/gateway/blocklist.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "go.uber.org/zap" + + "scrabble/gateway/internal/config" + "scrabble/gateway/internal/ratelimit" +) + +const ( + // blocklistFetchTimeout bounds one feed fetch. + blocklistFetchTimeout = 30 * time.Second + // maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a + // hostile or misconfigured URL from streaming unbounded data into memory). + maxBlocklistBytes = 8 << 20 +) + +// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into +// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose +// a protected range. +func parseAllowlist(entries []string) ([]*net.IPNet, error) { + out := make([]*net.IPNet, 0, len(entries)) + for _, e := range entries { + s := strings.TrimSpace(e) + if s == "" { + continue + } + if !strings.Contains(s, "/") { + s += "/32" + } + _, n, err := net.ParseCIDR(s) + if err != nil { + return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err) + } + out = append(out, n) + } + return out, nil +} + +// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then +// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a +// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled. +func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) { + client := &http.Client{Timeout: blocklistFetchTimeout} + for { + cidrs, err := fetchBlocklist(ctx, client, cfg.URL) + switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) { + case ratelimit.RefreshUpdated: + log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len())) + case ratelimit.RefreshKept: + log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err)) + case ratelimit.RefreshDropped: + log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err)) + } + select { + case <-ctx.Done(): + return + case <-time.After(cfg.Refresh): + } + } +} + +// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap. +func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode) + } + return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes)) +} diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index d08031d..b0f3745 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -129,6 +129,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Window: cfg.Abuse.BanWindow, Duration: cfg.Abuse.BanDuration, }) + allow, err := parseAllowlist(cfg.Blocklist.Allow) + if err != nil { + return err + } + blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow) hub := push.NewHub(0) var validator transcode.TelegramValidator @@ -222,6 +227,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Limiter: limiter, Tracker: tracker, Banlist: banlist, + Blocklist: blocklist, Honeytoken: cfg.Abuse.Honeytoken, VKAppSecret: cfg.VKAppSecret, Hub: hub, @@ -243,6 +249,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { if cfg.Abuse.BanEnabled { go runBanSync(ctx, banlist, backend, logger) } + // When the edge blocklist is enabled (prod), keep the community feed refreshed. + if cfg.Blocklist.Enabled { + go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger) + } public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout} servers := []*namedServer{{name: "public", srv: public}} diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 31fff16..2080c48 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" pkgtel "scrabble/pkg/telemetry" @@ -57,6 +58,8 @@ type Config struct { RateLimit RateLimitConfig // Abuse configures the temporary IP ban and the honeytoken (prod-only). Abuse AbuseConfig + // Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only). + Blocklist BlocklistConfig // Telemetry configures the OpenTelemetry providers (shared bootstrap). Telemetry pkgtel.Config } @@ -166,6 +169,42 @@ func DefaultAbuse() AbuseConfig { } } +// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP) +// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same +// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT +// test contour). Only IPv4 is matched. +type BlocklistConfig struct { + // Enabled turns edge blocklisting on. Off by default (prod-only). + Enabled bool + // URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the + // operator sets it explicitly so no feed URL is assumed. + URL string + // Refresh is how often the feed is re-fetched. + Refresh time.Duration + // MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped + // (fail-open — a legitimate client is never blocked on a frozen feed). + MaxStaleness time.Duration + // Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that + // the feed can never block. Parsed at startup. + Allow []string +} + +// Blocklist defaults; the feed URL has no default (the operator sets it). +const ( + defaultBlocklistRefresh = 6 * time.Hour + defaultBlocklistMaxStaleness = 48 * time.Hour +) + +// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and +// the agreed refresh / staleness windows. +func DefaultBlocklist() BlocklistConfig { + return BlocklistConfig{ + Enabled: false, + Refresh: defaultBlocklistRefresh, + MaxStaleness: defaultBlocklistMaxStaleness, + } +} + // VKIDConfig holds the VK ID web-login credentials for the confidential // authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is // its protected key; RedirectURI must exactly match the trusted redirect URL registered @@ -203,6 +242,7 @@ func Load() (Config, error) { SessionCacheMax: defaultSessionCacheMax, RateLimit: DefaultRateLimit(), Abuse: DefaultAbuse(), + Blocklist: DefaultBlocklist(), BotLink: BotLinkConfig{ Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"), RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"), @@ -244,6 +284,17 @@ func Load() (Config, error) { if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil { return Config{}, err } + if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil { + return Config{}, err + } + c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL") + if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil { + return Config{}, err + } + if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil { + return Config{}, err + } + c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW")) if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil { return Config{}, err } @@ -284,6 +335,9 @@ func (c Config) validate() error { if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) { return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED") } + if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) { + return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED") + } if c.BotLink.Addr != "" { if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" { return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA") @@ -304,6 +358,21 @@ func envOr(key, fallback string) string { return fallback } +// splitList splits a comma-separated environment value into trimmed, non-empty items. +func splitList(v string) []string { + if v == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + // envBool parses the environment variable named key as a bool, returning fallback // when it is unset and an error when it is set but malformed. func envBool(key string, fallback bool) (bool, error) { diff --git a/gateway/internal/connectsrv/abuse_test.go b/gateway/internal/connectsrv/abuse_test.go index 05e65e6..23015c3 100644 --- a/gateway/internal/connectsrv/abuse_test.go +++ b/gateway/internal/connectsrv/abuse_test.go @@ -2,6 +2,7 @@ package connectsrv_test import ( "context" + "net" "net/http" "net/http/httptest" "testing" @@ -24,7 +25,7 @@ const honeypotHeader = "X-Scrabble-Honeypot" // guardedEdge wires an edge with an explicit banlist and honeytoken over a fake // backend, returning the front URL, a Connect client and a cleanup func. -func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) { +func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) { t.Helper() backendSrv := httptest.NewServer(backendHandler) backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) @@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits Sessions: session.NewCache(backend, time.Minute, 100), Limiter: ratelimit.New(), Banlist: bl, + Blocklist: block, Honeytoken: honeytoken, Hub: push.NewHub(0), RateLimit: limits, @@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist { func TestAbuseGuardBlocksBannedIP(t *testing.T) { bl := enabledBanlist(100) bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire) - url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) + url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() resp, err := noRedirect().Get(url + "/") @@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) { // and bans the client IP, so the next request is blocked. func TestHoneypotHeaderTrips(t *testing.T) { bl := enabledBanlist(100) - url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { + url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called for a honeypot hit") }) defer cleanup() @@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) { // banlist still 404s the decoy (detection/logging) but bans nothing. func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) { bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled - url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) + url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil) @@ -150,7 +152,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) { bl := enabledBanlist(1) limits := config.DefaultRateLimit() limits.PublicPerMinute, limits.PublicBurst = 1, 1 - _, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) { + _, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) }) defer cleanup() @@ -173,7 +175,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) { bl := enabledBanlist(1) limits := config.DefaultRateLimit() limits.UserPerMinute, limits.UserBurst = 1, 1 - _, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) { + _, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/internal/sessions/resolve": _, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`)) @@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) { // caller and returns the ordinary invalid-session error without a backend call. func TestHoneytokenBansAndRejects(t *testing.T) { bl := enabledBanlist(100) - _, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { + _, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called for the honeytoken") }) defer cleanup() @@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) { t.Fatal("the honeytoken must ban the caller") } } + +// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused +// with 403 at the HTTP layer, before any handler runs. +func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) { + block := ratelimit.NewBlocklist(true, nil) + _, feed, err := net.ParseCIDR("127.0.0.0/8") + if err != nil { + t.Fatal(err) + } + block.SetCIDRs([]*net.IPNet{feed}, time.Now()) + url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + resp, err := noRedirect().Get(url + "/") + if err != nil { + t.Fatalf("get: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode) + } +} diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index ff35ece..157b8af 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -7,6 +7,8 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/noop" + + "scrabble/gateway/internal/ratelimit" ) // meterName scopes the gateway edge's OpenTelemetry instruments. @@ -27,6 +29,7 @@ type serverMetrics struct { edge metric.Float64Histogram rateLimited metric.Int64Counter banned metric.Int64Counter + blocklisted metric.Int64Counter active *activeUsers // Client-reported local move-preview adoption (see localEvalMetricsHandler). localColdStart metric.Int64Counter @@ -40,7 +43,7 @@ type serverMetrics struct { // falling back to a no-op histogram on the (rare) construction error. The // active_users gauge is registered as an observable callback over the in-memory // tracker. -func newServerMetrics(meter metric.Meter) *serverMetrics { +func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics { if meter == nil { meter = noop.NewMeterProvider().Meter(meterName) } @@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { } m := &serverMetrics{ edge: h, rateLimited: c, banned: b, active: newActiveUsers(), + blocklisted: counterOf(meter, "gateway_blocklist_blocked_total", + "Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."), localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), @@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { return nil }, gauge) } + + // Community blocklist status: the feed size and how stale it is, for the dashboard and the + // "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or + // never-fetched blocklist reports no age (and entries 0). + if bl != nil { + entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries", + metric.WithDescription("CIDR ranges in the active community IP blocklist feed.")) + age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds", + metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch).")) + if e1 == nil && e2 == nil { + _, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error { + o.ObserveInt64(entries, int64(bl.Len())) + if last := bl.LastFetch(); !last.IsZero() { + o.ObserveFloat64(age, time.Since(last).Seconds()) + } + return nil + }, entries, age) + } + } return m } @@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) { m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) } +// recordBlocklistBlock counts one request refused by the community IP blocklist. +func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) { + m.blocklisted.Add(ctx, 1) +} + // recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen, // labelled by reason and Chromium major. The caller passes both already reduced to bounded label // sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality. diff --git a/gateway/internal/connectsrv/metrics_test.go b/gateway/internal/connectsrv/metrics_test.go index d776798..9e51f05 100644 --- a/gateway/internal/connectsrv/metrics_test.go +++ b/gateway/internal/connectsrv/metrics_test.go @@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) { ctx := context.Background() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - m := newServerMetrics(meter) + m := newServerMetrics(meter, nil) m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond)) m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond)) @@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) { ctx := context.Background() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - m := newServerMetrics(meter) + m := newServerMetrics(meter, nil) m.recordRateLimited(ctx, "user") m.recordRateLimited(ctx, "user") @@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) { ctx := context.Background() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - m := newServerMetrics(meter) + m := newServerMetrics(meter, nil) m.recordBan(ctx, "tripwire") m.recordBan(ctx, "tripwire") @@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) { ctx := context.Background() reader := sdkmetric.NewManualReader() meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") - m := newServerMetrics(meter) + m := newServerMetrics(meter, nil) m.recordUnsupportedEngine(ctx, "no_bigint", "66") m.recordUnsupportedEngine(ctx, "no_bigint", "66") diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 72b1a74..c8c9a52 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -77,6 +77,7 @@ type Server struct { limiter *ratelimit.Limiter tracker *ratelimit.Tracker banlist *ratelimit.Banlist + blocklist *ratelimit.Blocklist honeytoken string vkAppSecret string hub *push.Hub @@ -109,6 +110,9 @@ type Deps struct { // Banlist enforces temporary IP bans on the hot path; nil selects a disabled // (inert) banlist. Banlist *ratelimit.Banlist + // Blocklist enforces the community IP blocklist on the hot path; nil selects a + // disabled (inert) blocklist. + Blocklist *ratelimit.Blocklist // Honeytoken, when non-empty, is the planted bearer value whose presentation // bans the caller and raises a high-severity alarm. Honeytoken string @@ -148,6 +152,10 @@ func NewServer(d Deps) *Server { if banlist == nil { banlist = ratelimit.NewBanlist(ratelimit.BanConfig{}) } + blocklist := d.Blocklist + if blocklist == nil { + blocklist = ratelimit.NewBlocklist(false, nil) + } rl := d.RateLimit if rl == (config.RateLimitConfig{}) { rl = config.DefaultRateLimit() @@ -160,12 +168,13 @@ func NewServer(d Deps) *Server { limiter: limiter, tracker: tracker, banlist: banlist, + blocklist: blocklist, honeytoken: d.Honeytoken, hub: d.Hub, heartbeat: d.Heartbeat, log: log, adminProxy: d.AdminProxy, - metrics: newServerMetrics(d.Meter), + metrics: newServerMetrics(d.Meter, blocklist), maxBodyBytes: maxBody, publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst), userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst), @@ -255,6 +264,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler { http.Error(w, "banned", http.StatusTooManyRequests) return } + // The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work. + // Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist. + if s.blocklist.Blocked(ip) { + s.metrics.recordBlocklistBlock(r.Context()) + http.Error(w, "forbidden", http.StatusForbidden) + return + } if r.Header.Get(honeypotHeader) != "" { s.log.Warn("honeypot tripwire", zap.String("path", r.URL.Path), diff --git a/gateway/internal/ratelimit/blocklist.go b/gateway/internal/ratelimit/blocklist.go new file mode 100644 index 0000000..18a8a53 --- /dev/null +++ b/gateway/internal/ratelimit/blocklist.go @@ -0,0 +1,188 @@ +package ratelimit + +import ( + "bufio" + "encoding/binary" + "fmt" + "io" + "net" + "sort" + "strings" + "sync" + "time" +) + +// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against. +type ipRange struct{ lo, hi uint32 } + +// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced +// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an +// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is +// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it). +// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false. +type Blocklist struct { + enabled bool + allow []ipRange // sorted, non-overlapping; from config, immutable after construction + + mu sync.RWMutex + ranges []ipRange // sorted, non-overlapping; the current feed + fetchedAt time.Time // last successful SetCIDRs; zero = never loaded +} + +// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set +// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it. +func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist { + return &Blocklist{enabled: enabled, allow: toRanges(allow)} +} + +// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is +// false on a disabled or empty blocklist, and false for any non-IPv4 address. +func (b *Blocklist) Blocked(ip string) bool { + if !b.enabled { + return false + } + v, ok := ipv4ToUint32(ip) + if !ok { + return false + } + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.ranges) == 0 || rangesContain(b.allow, v) { + return false + } + return rangesContain(b.ranges, v) +} + +// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored. +func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) { + r := toRanges(cidrs) + b.mu.Lock() + b.ranges = r + b.fetchedAt = at + b.mu.Unlock() +} + +// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps +// climbing. Blocked then returns false until a fresh feed loads. +func (b *Blocklist) Clear() { + b.mu.Lock() + b.ranges = nil + b.mu.Unlock() +} + +// Len returns the number of ranges currently enforced. +func (b *Blocklist) Len() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.ranges) +} + +// LastFetch returns the time of the last successful feed load (zero if never). +func (b *Blocklist) LastFetch() time.Time { + b.mu.RLock() + defer b.mu.RUnlock() + return b.fetchedAt +} + +// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics. +type RefreshOutcome int + +const ( + // RefreshUpdated: a new feed was fetched and applied. + RefreshUpdated RefreshOutcome = iota + // RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept. + RefreshKept + // RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open). + RefreshDropped +) + +// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it +// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open +// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock. +func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome { + if fetchErr == nil { + bl.SetCIDRs(cidrs, now) + return RefreshUpdated + } + last := bl.LastFetch() + if last.IsZero() || now.Sub(last) > maxStaleness { + bl.Clear() + return RefreshDropped + } + return RefreshKept +} + +// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail, +// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and +// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed. +func ParseDROP(r io.Reader) ([]*net.IPNet, error) { + var out []*net.IPNet + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + line := sc.Text() + if i := strings.IndexByte(line, ';'); i >= 0 { + line = line[:i] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.Contains(line, "/") { + line += "/32" + } + _, ipnet, err := net.ParseCIDR(line) + if err != nil || ipnet.IP.To4() == nil { + continue + } + out = append(out, ipnet) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("ratelimit: read blocklist: %w", err) + } + return out, nil +} + +// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs +// are dropped. Sorting by lo lets [rangesContain] binary-search. +func toRanges(cidrs []*net.IPNet) []ipRange { + out := make([]ipRange, 0, len(cidrs)) + for _, n := range cidrs { + if n == nil { + continue + } + ip4 := n.IP.To4() + if ip4 == nil { + continue + } + ones, bits := n.Mask.Size() + if bits != 32 { + continue + } + lo := binary.BigEndian.Uint32(ip4) + hi := lo | (uint32(0xffffffff) >> uint(ones)) + out = append(out, ipRange{lo: lo, hi: hi}) + } + sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo }) + return out +} + +// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a +// binary search for the last range whose lo is at most v. +func rangesContain(ranges []ipRange, v uint32) bool { + i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v }) + return i > 0 && ranges[i-1].hi >= v +} + +// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4. +func ipv4ToUint32(s string) (uint32, bool) { + ip := net.ParseIP(s) + if ip == nil { + return 0, false + } + ip4 := ip.To4() + if ip4 == nil { + return 0, false + } + return binary.BigEndian.Uint32(ip4), true +} diff --git a/gateway/internal/ratelimit/blocklist_test.go b/gateway/internal/ratelimit/blocklist_test.go new file mode 100644 index 0000000..4652b7f --- /dev/null +++ b/gateway/internal/ratelimit/blocklist_test.go @@ -0,0 +1,106 @@ +package ratelimit + +import ( + "errors" + "net" + "strings" + "testing" + "time" +) + +// cidrs parses CIDR strings into networks for a test fixture. +func cidrs(t *testing.T, ss ...string) []*net.IPNet { + t.Helper() + out := make([]*net.IPNet, 0, len(ss)) + for _, s := range ss { + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("bad cidr %q: %v", s, err) + } + out = append(out, n) + } + return out +} + +func TestBlocklistBlocked(t *testing.T) { + bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist + bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now()) + cases := map[string]bool{ + "1.2.3.0": true, // range start + "1.2.3.255": true, // range end + "1.2.4.0": false, // just past the /24 + "203.0.113.5": true, // /32 exact + "203.0.113.6": false, + "198.51.100.15": true, // within /28 + "198.51.100.16": false, // past the /28 + "9.9.9.9": false, // not listed + "10.20.30.99": false, // listed BUT allowlisted — never blocked + "::1": false, // IPv6 — never matched here + "not-an-ip": false, + } + for ip, want := range cases { + if got := bl.Blocked(ip); got != want { + t.Errorf("Blocked(%q) = %v, want %v", ip, got, want) + } + } +} + +func TestBlocklistDisabledOrEmpty(t *testing.T) { + off := NewBlocklist(false, nil) + off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now()) + if off.Blocked("1.2.3.4") { + t.Error("a disabled blocklist must not block") + } + empty := NewBlocklist(true, nil) + if empty.Blocked("1.2.3.4") { + t.Error("an empty (never-loaded) blocklist must not block") + } +} + +func TestParseDROP(t *testing.T) { + in := "; Spamhaus DROP\n" + + "1.2.3.0/24 ; SBL1\n" + + " 198.51.100.0/28 ; SBL2\n" + + "\n" + + "203.0.113.5 ; a bare IP -> /32\n" + + "2001:db8::/32 ; IPv6 skipped\n" + + "garbage line\n" + nets, err := ParseDROP(strings.NewReader(in)) + if err != nil { + t.Fatal(err) + } + if len(nets) != 3 { + t.Fatalf("got %d networks, want 3: %v", len(nets), nets) + } + bl := NewBlocklist(true, nil) + bl.SetCIDRs(nets, time.Now()) + for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} { + if got := bl.Blocked(ip); got != want { + t.Errorf("Blocked(%s) = %v, want %v", ip, got, want) + } + } +} + +func TestApplyRefresh(t *testing.T) { + bl := NewBlocklist(true, nil) + now := time.Now() + + if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated { + t.Fatalf("success: want RefreshUpdated, got %v", o) + } + if !bl.Blocked("1.2.3.4") { + t.Fatal("a successful refresh must apply the feed") + } + if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept { + t.Fatalf("fresh failure: want RefreshKept, got %v", o) + } + if !bl.Blocked("1.2.3.4") { + t.Error("a kept feed must still block") + } + if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped { + t.Fatalf("stale failure: want RefreshDropped, got %v", o) + } + if bl.Blocked("1.2.3.4") { + t.Error("a stale feed must be dropped (fail-open)") + } +}
200.00