diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3283130..00df876 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -298,17 +298,21 @@ jobs: # pick up the fresh config. docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana - - name: Probe the gateway through caddy + - name: Probe the landing and the gateway through caddy run: | set -u + # Two probes through the contour caddy (R3 split): "/" is the static + # landing container, "/app/" is the gateway-served SPA shell. for i in $(seq 1 20); do - if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/; then - echo "healthy: GET http://scrabble/" + if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ && + docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then + echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)" exit 0 fi sleep 3 done - echo "probe failed; recent gateway logs:" + echo "probe failed; recent landing + gateway logs:" + docker logs --tail 50 scrabble-landing || true docker logs --tail 50 scrabble-gateway || true exit 1 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index ba26fbe..8860263 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,7 +1,9 @@ # Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers # every operator surface under /_gm (the backend-rendered admin console and the -# Grafana subpath); everything else (the SPA at / and /telegram/, plus the -# Connect edge) goes to the gateway. Mirrors ../galaxy-game's /_gm model. +# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to +# the gateway; the catch-all — notably the public landing at / — goes to the +# static landing container (R3), so stray traffic never reaches the Go edge. +# Mirrors ../galaxy-game's /_gm model. # # CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS # and forwards); set it to a domain in prod (Stage 18) so this caddy does its own @@ -36,8 +38,14 @@ } } - # The SPA (/, /telegram/) and the Connect edge are served by the gateway. - handle { + # The game SPA and the Connect edge are served by the gateway. + @gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/* + handle @gateway { reverse_proxy gateway:8081 } + + # 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 cb071f6..953825a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -75,6 +75,7 @@ services: build: context: .. dockerfile: gateway/Dockerfile + target: gateway args: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} @@ -100,6 +101,28 @@ services: # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly. networks: [internal] + # --- Landing (static) ------------------------------------------------------- + # The public landing page in its own caddy container (R3): the contour caddy + # routes the catch-all (notably /) here, the gateway keeps only /app/, + # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build + # stage — identical build args keep that stage a single cached build. + landing: + container_name: scrabble-landing + image: scrabble-landing:latest + build: + context: .. + dockerfile: gateway/Dockerfile + target: landing + args: + VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} + VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} + VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} + VITE_APP_VERSION: ${APP_VERSION:-dev} + restart: unless-stopped + networks: [internal] + # --- Telegram connector (egress via the VPN sidecar) ----------------------- vpn: container_name: scrabble-telegram-vpn @@ -145,12 +168,13 @@ services: OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" - # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway) -- + # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway; + # the catch-all incl. the landing -> the static landing container) ------- caddy: container_name: scrabble-caddy image: caddy:2-alpine restart: unless-stopped - depends_on: [gateway, backend, grafana] + depends_on: [gateway, backend, grafana, landing] environment: # Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME. CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80} diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile new file mode 100644 index 0000000..f14b522 --- /dev/null +++ b/deploy/landing/Caddyfile @@ -0,0 +1,27 @@ +# Static landing container (R3). Serves the public landing page and the built +# assets it references at /; the game SPA (/app/, /telegram/) and the Connect +# edge stay on the gateway. The contour caddy routes the catch-all here, so +# stray public paths are absorbed by static file serving and never reach the Go +# edge. This file is baked into the image at build time (gateway/Dockerfile, +# target `landing`), not bind-mounted. +{ + admin off +} + +:80 { + root * /srv + encode zstd gzip + + # Mirror the gateway webui caching: hash-named build assets are immutable, + # every HTML shell is no-cache so a new deploy is picked up immediately. + header /assets/* Cache-Control "public, max-age=31536000, immutable" + @shell not path /assets/* + header @shell Cache-Control "no-cache" + + # An unknown path falls back to the landing shell (the gateway's old "/" + # behaviour); "/" itself resolves through the index below. + try_files {path} /landing.html + file_server { + index landing.html + } +} diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 8a45a94..092fd6c 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,15 +1,18 @@ -# Multi-stage build for the gateway service. A node stage builds the static UI -# (Vite), the result is embedded into the Go binary (gateway/internal/webui/dist), -# and the Go stage — mirroring platform/telegram/Dockerfile — yields a static -# binary shipped on distroless nonroot. So the single binary serves the SPA at / -# and /telegram/ (docs/ARCHITECTURE.md §13) with no separate static container. +# Multi-stage build for the gateway service and the landing image. A node stage +# builds the static UI (Vite) once; the `landing` target serves that build from a +# static caddy container (the public landing at /, R3), while the final gateway +# target embeds it — minus landing.html — into the Go binary +# (gateway/internal/webui/dist), which serves the game SPA at /app/ and +# /telegram/ (docs/ARCHITECTURE.md §13). The Go stage mirrors +# platform/telegram/Dockerfile and ships on distroless nonroot. # # The production UI build vars are image build-args, baked into the bundle. -# Build from the repository root so go.work, pkg/, gateway/ and ui/ are all in the -# Docker context: +# Build from the repository root so go.work, pkg/, gateway/, ui/ and +# deploy/landing/ are all in the Docker context: # docker build -f gateway/Dockerfile \ # --build-arg VITE_GATEWAY_URL=https://example \ # -t scrabble-gateway . +# docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # --- UI build ---------------------------------------------------------------- FROM node:22-alpine AS ui @@ -38,6 +41,14 @@ RUN pnpm install --frozen-lockfile COPY ui ./ RUN pnpm build +# --- landing ------------------------------------------------------------------- +# The public landing page as its own static container (R3): the same Vite build +# served by caddy at /, so stray public traffic is absorbed by static file +# serving and never reaches the Go edge. +FROM caddy:2-alpine AS landing +COPY deploy/landing/Caddyfile /etc/caddy/Caddyfile +COPY --from=ui /ui/dist /srv + # --- Go build ---------------------------------------------------------------- FROM golang:1.26.3-alpine AS build WORKDIR /src @@ -46,9 +57,11 @@ COPY pkg ./pkg COPY gateway ./gateway # Replace the committed placeholder with the freshly built UI before compiling, so -# go:embed bakes the real bundle into the binary. +# go:embed bakes the real bundle into the binary. The landing shell ships in the +# landing image, not in the gateway (R3). RUN rm -rf gateway/internal/webui/dist COPY --from=ui /ui/dist gateway/internal/webui/dist +RUN rm gateway/internal/webui/dist/landing.html # Reduce the workspace to what the gateway needs: gateway + pkg (loadtest is not in # this context; its scrabble/gateway replace targets ./gateway, which is present here). @@ -56,6 +69,6 @@ RUN go work edit -dropuse=./backend -dropuse=./platform/telegram -dropuse=./load RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/gateway ./gateway/cmd/gateway # --- runtime ----------------------------------------------------------------- -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian12:nonroot AS gateway COPY --from=build /out/gateway /usr/local/bin/gateway ENTRYPOINT ["/usr/local/bin/gateway"] diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 5c6fef0..f1880d3 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -158,14 +158,16 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } - // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini - // App), with a separate landing page at the catch-all "/" — the single-origin model - // (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more - // specific prefix) keeps priority. Each SPA mount falls back to the app shell - // (index.html) for the hash router; "/" falls back to the landing (landing.html). + // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram + // Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below + // the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and + // each mount falls back to the app shell (index.html) for the hash router. The + // public landing moved to its own static container behind the contour caddy + // (R3), so the catch-all redirects a stray root hit to the app shell — which + // keeps a local no-caddy run usable. mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html")) - mux.Handle("/", webui.Handler("", "landing.html")) + mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) // Every request body on the public listener is capped (the admin proxy POSTs // included); the h2c server carries explicit stream/idle sizing (R3). return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{ diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go index 226f43b..d5c8f52 100644 --- a/gateway/internal/connectsrv/server_test.go +++ b/gateway/internal/connectsrv/server_test.go @@ -197,6 +197,26 @@ func TestExecuteOversizedPayloadRejected(t *testing.T) { } } +// TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/" +// (it lives in the landing container since R3): a stray root hit is redirected +// to the app shell. +func TestRootRedirectsToApp(t *testing.T) { + front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler()) + defer front.Close() + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Get(front.URL + "/") + if err != nil { + t.Fatalf("get /: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusPermanentRedirect || resp.Header.Get("Location") != "/app/" { + t.Fatalf("GET / = %d -> %q, want 308 -> /app/", resp.StatusCode, resp.Header.Get("Location")) + } +} + func TestExecuteUnknownMessageType(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() diff --git a/gateway/internal/webui/dist/landing.html b/gateway/internal/webui/dist/landing.html deleted file mode 100644 index f06ae67..0000000 --- a/gateway/internal/webui/dist/landing.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - Scrabble - - - -

- Landing build placeholder. The production gateway image embeds the real Vite - build (see gateway/Dockerfile); seeing this page means the binary was built - without a UI build. -

- - diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index 5bde63e..2336265 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -1,12 +1,13 @@ // Package webui serves the embedded static UI build over the public edge. // -// The committed dist/ holds only placeholder index.html / landing.html so the gateway -// module compiles with a plain `go build` (and in CI) without a UI build. The production -// gateway image replaces dist/ with the real Vite build before compiling (see -// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built with a -// relative asset base, one build serves under any path: the game SPA is mounted at /app/ -// (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the -// single-origin model in docs/ARCHITECTURE.md §13. +// The committed dist/ holds only a placeholder index.html so the gateway module +// compiles with a plain `go build` (and in CI) without a UI build. The production +// gateway image replaces dist/ with the real Vite build — minus landing.html, which +// ships in the separate landing container since R3 — before compiling (see +// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built +// with a relative asset base, one build serves under any path: the game SPA is +// mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin +// model in docs/ARCHITECTURE.md §13. // // Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and // cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry @@ -35,10 +36,10 @@ func distFS() fs.FS { } // Handler serves the embedded UI. An existing file is served directly (hash-named assets get -// an immutable cache); every other path falls back to indexName (the SPA shell or the landing -// page) so a client-side deep link still loads. When stripPrefix is non-empty it is removed -// from the request path before lookup, so the same build serves under a sub-path (e.g. -// "/app/" or "/telegram/"). +// an immutable cache); every other path falls back to indexName (the SPA shell) so a +// client-side deep link still loads. When stripPrefix is non-empty it is removed from the +// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or +// "/telegram/"). func Handler(stripPrefix, indexName string) http.Handler { content := distFS() files := http.FileServer(http.FS(content)) diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index c7a4d4a..c40d30f 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -22,20 +22,12 @@ func body(t *testing.T, resp *http.Response) string { return string(b) } -// TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and -// any unknown path falls back to it. -func TestLandingMountServesLandingAndFallsBack(t *testing.T) { - h := Handler("", "landing.html") - - resp := get(t, h, "/") - if resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-landing") { - t.Fatalf("GET / did not serve the landing shell (status %d)", resp.StatusCode) - } - if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" { - t.Errorf("landing Cache-Control = %q, want no-cache", cc) - } - if resp := get(t, h, "/whatever"); resp.StatusCode != http.StatusOK { - t.Fatalf("GET /whatever status = %d, want 200 (fallback)", resp.StatusCode) +// TestShellNoCache: the served HTML shell carries no-cache so a new deploy's +// shell (and the asset URLs it references) is fetched fresh. +func TestShellNoCache(t *testing.T) { + h := Handler("/app/", "index.html") + if cc := get(t, h, "/app/").Header.Get("Cache-Control"); cc != "no-cache" { + t.Errorf("shell Cache-Control = %q, want no-cache", cc) } }