diff --git a/.gitea/workflows/chart-release.yml b/.gitea/workflows/chart-release.yml index c024b7f..3c8bbe0 100644 --- a/.gitea/workflows/chart-release.yml +++ b/.gitea/workflows/chart-release.yml @@ -140,6 +140,11 @@ jobs: --set ingress.enabled=true \ --set ingress.web.host=vantage.example.com \ --set server.env.grpcHost=agents.example.com:443 + refuses "an ingress that leaves /api unrouted" \ + --set ingress.enabled=true \ + --set ingress.web.host=vantage.example.com \ + --set ingress.grpc.enabled=false \ + --set ingress.api.enabled=false refuses "gRPC ingress while grpcHost is still in-cluster" \ --set ingress.enabled=true \ --set ingress.web.host=vantage.example.com \ diff --git a/CLAUDE.md b/CLAUDE.md index f404e8f..e5ea4f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1067,7 +1067,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a **`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths. -**`ingress.api.enabled` routes `/api` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed. +**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed. **The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `-server` would force h2c on its HTTP port too. @@ -1077,6 +1077,8 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) * --- +**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all. + `deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk. `docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s. @@ -1235,7 +1237,7 @@ git push origin main # server + web deploy | `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected | | ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` | | `DOCKER_HOST` | Variable | registry host used for image tags | -| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. | +| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. | | `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. | | `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured | | `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. | diff --git a/deploy/chart/vantage/Chart.yaml b/deploy/chart/vantage/Chart.yaml index 56147dc..566e6dd 100644 --- a/deploy/chart/vantage/Chart.yaml +++ b/deploy/chart/vantage/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: vantage description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web) type: application -version: 1.0.8 +version: 1.1.0 appVersion: "1.0.8" diff --git a/deploy/chart/vantage/templates/NOTES.txt b/deploy/chart/vantage/templates/NOTES.txt index 1f41850..09c39fe 100644 --- a/deploy/chart/vantage/templates/NOTES.txt +++ b/deploy/chart/vantage/templates/NOTES.txt @@ -41,12 +41,9 @@ Ingress (Traefik): {{- range .Values.ingress.web.extraHosts }} https://{{ . }} {{- end }} -{{- if .Values.ingress.api.enabled }} - {{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web. -{{- else }} - Everything goes to web, which proxies /api and /auth onward. Set - ingress.api.enabled=true to route them at the edge instead. -{{- end }} + {{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web. + web proxies nothing, so those paths must be routed here or by a terminator + in front of this ingress. {{- if .Values.ingress.grpc.enabled }} - Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS) Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}. diff --git a/deploy/chart/vantage/templates/ingress.yaml b/deploy/chart/vantage/templates/ingress.yaml index 44cd073..a212fc2 100644 --- a/deploy/chart/vantage/templates/ingress.yaml +++ b/deploy/chart/vantage/templates/ingress.yaml @@ -2,16 +2,14 @@ {{/* Two hostnames, because the two audiences arrive over different protocols. -Browsers reach the web host. What answers there depends on the path: with -ingress.api.enabled, /api and /auth go straight to the server and everything -else goes to `web`. Without it, everything goes to `web`, which proxies those -prefixes onward itself (web/next.config.ts). +Browsers reach the web host, and the path decides what answers: /api, /auth, +/public, /install* and /update* go to the server, everything else to `web`. -Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy -Manager deployment in front of the Docker install already does, so leaving it -off changes the shape of the request path between the two deployments. It is -still off by default, because turning it on where `web` is the only thing with -a public certificate would strand /api behind a route nobody can reach. +That split is not optional and ingress.api.enabled defaults to true. `web` +proxies nothing — it holds no address for the server at all — so with these +paths absent the UI loads and every request it makes 404s against Next. The +setting remains a value only so an installation terminating in front of this +ingress can route the prefixes itself; it must be routed somewhere. The web host is normally a wildcard — `*.vantage.example.com` — because that is the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the @@ -35,6 +33,9 @@ its HTTP port too. {{- if and .Values.ingress.api.enabled (not $apiPaths) }} {{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }} {{- end }} +{{- if not .Values.ingress.api.enabled }} +{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }} +{{- end }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: diff --git a/deploy/chart/vantage/templates/web.yaml b/deploy/chart/vantage/templates/web.yaml index 6c8ed89..21439fe 100644 --- a/deploy/chart/vantage/templates/web.yaml +++ b/deploy/chart/vantage/templates/web.yaml @@ -38,12 +38,10 @@ spec: image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}" ports: - containerPort: {{ .Values.web.service.port }} - env: - - name: API_URL - value: {{ tpl .Values.web.env.apiUrl . | quote }} - # /healthz is served by this Next process; /api is rewritten to the - # server, so a probe there would report the backend's health and keep - # passing while this pod was wedged. + # /healthz is served by this Next process. /api never reaches this + # pod at all — the ingress routes it to the server — so there is no + # backend address to configure and no probe here that could report + # the backend's health by accident. startupProbe: httpGet: path: /healthz diff --git a/deploy/chart/vantage/values.yaml b/deploy/chart/vantage/values.yaml index f325573..4ba7f6a 100644 --- a/deploy/chart/vantage/values.yaml +++ b/deploy/chart/vantage/values.yaml @@ -79,8 +79,6 @@ web: service: type: ClusterIP port: 3000 - env: - apiUrl: "http://{{ .Release.Name }}-server:8080" ingress: enabled: false @@ -90,8 +88,10 @@ ingress: web: host: "" extraHosts: [] + # Not optional: web proxies nothing, so these prefixes reach the server + # only through this ingress. Turning it off serves the UI with a dead API. api: - enabled: false + enabled: true paths: - /api/ - /auth/ diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 84e0efb..559d0cd 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -60,8 +60,10 @@ services: restart: unless-stopped ports: - 3000:3000 - environment: - API_URL: ${API_URL:-http://server:8080} + # No API_URL: web proxies nothing. The reverse proxy in front of this + # deployment must route /api, /auth, /public, /install*, /update* to + # server:8080 and everything else to web:3000. Reaching web:3000 + # directly serves the UI and every API call 404s. depends_on: - server volumes: diff --git a/docsite/docs/getting-started/self-hosted-install.md b/docsite/docs/getting-started/self-hosted-install.md index b9f413f..c56fa21 100644 --- a/docsite/docs/getting-started/self-hosted-install.md +++ b/docsite/docs/getting-started/self-hosted-install.md @@ -96,8 +96,16 @@ rather than run in a half-prepared state. ## 4. Put a proxy in front -Point your reverse proxy at `web` on port `3000` and terminate TLS there. The -web app reaches the API internally, so there is no need to publish port `8080`. +Terminate TLS at your reverse proxy and route **one hostname to two backends**: + +| Path | Backend | +| -------------------------------------------------------------------------------- | ------------- | +| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` | +| everything else | `web:3000` | + +Both rules are required. The web app forwards nothing to the API, so a proxy +that sends the whole hostname to `web:3000` serves the interface and answers +`404` to every request it makes — starting with the login form. Agents connect to port `9090`. Vantage does not terminate TLS itself, so put that port behind your proxy too, with a certificate valid for the name in diff --git a/docsite/docs/reference/ports-and-networking.md b/docsite/docs/reference/ports-and-networking.md index e1d9e49..2aaa4e2 100644 --- a/docsite/docs/reference/ports-and-networking.md +++ b/docsite/docs/reference/ports-and-networking.md @@ -9,7 +9,7 @@ sidebar_label: Ports and networking | Port | Service | Who connects | Expose publicly | | ------- | ----------- | -------------------------------- | --------------- | | `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS | -| `8080` | server API | The web app | No, firewall it | +| `8080` | server API | Your reverse proxy | Not directly — proxied | | `9090` | server gRPC | Agents | **Yes** | | `4822` | guacd | The server | No, firewall it | | `27017` | MongoDB | The server | No | @@ -20,8 +20,8 @@ sidebar_label: Ports and networking ```mermaid flowchart LR B["Browser"] -->|HTTPS| P["Reverse proxy"] - P --> W["web :3000"] - W --> S["server :8080"] + P -->|"everything else"| W["web :3000"] + P -->|"/api /auth /public /install* /update*"| S["server :8080"] A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S S --> G["guacd :4822"] G -->|"relayed over the :9090 stream"| A @@ -77,8 +77,20 @@ On a private network you can skip TLS instead, by setting `tls: false` in each ## Reverse proxy notes -- Point the proxy at `web:3000`. The web app reaches the API internally, so - `8080` does not need publishing. +- **The proxy routes two backends on one hostname**, and both are required: + + | Path | Backend | + | ------------------------------------------------------------- | ------------- | + | `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` | + | everything else | `web:3000` | + + The web app forwards nothing to the API. Sending the whole hostname to + `web:3000` loads the interface and every request it makes answers `404` — + including the login form. + +- Both backends must be the **same** hostname and certificate. The browser + calls `/api` relative to the page it is on, and the session cookie is + host-only. - The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does not forward upgrade headers breaks the console and nothing else. - Workflow log streaming is a long-lived response. A short proxy read timeout diff --git a/docsite/docs/reference/troubleshooting.md b/docsite/docs/reference/troubleshooting.md index a539364..070ad21 100644 --- a/docsite/docs/reference/troubleshooting.md +++ b/docsite/docs/reference/troubleshooting.md @@ -20,6 +20,13 @@ or is not 64 hex characters. ## Nobody can sign in +**Every request 404s and the interface loads fine.** Your reverse proxy sends +the whole hostname to `web:3000`. `/api`, `/auth`, `/public`, `/install*` and +`/update*` belong to `server:8080` and the web app forwards nothing — see +[Ports and networking](./ports-and-networking.md#reverse-proxy-notes). The +tell is `curl -si https:///auth/bootstrap-status` returning HTML +with `x-powered-by: Next.js` instead of JSON. + **`/setup` appears when users already exist.** The server is pointed at a different database than you think. Check the database name in `MONGO_URI`, which is taken from the end of the URI. @@ -169,6 +176,11 @@ is `.vantage./status/`, the same per-instance subdomain everything else in Vantage uses. A wrong or missing subdomain resolves to no instance at all, which is also a 404. +Third possibility: `/public` is not routed to the server. Check with +`curl -si https://.vantage./public/status/` +— JSON is correct, HTML carrying `x-powered-by: Next.js` means the proxy sent +that prefix to the web app. + **Loads, but shows an explanation instead of components.** This is not a fault — it is the page working as designed. It means either the licence has lapsed (a self-hosted instance past its grace period, or a cloud instance diff --git a/web/Dockerfile b/web/Dockerfile index 2afbfb4..468d934 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -30,10 +30,9 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -# Control-plane URL the Next server proxies /api, /auth and /install to. Runtime, -# not build time: next.config.js is evaluated when server.js boots in standalone -# mode, so this is overridable per deployment without a rebuild. -ENV API_URL=http://localhost:8080 +# No control-plane address here on purpose: this app proxies nothing. /api, +# /auth, /install, /update and /public are routed to the server by the reverse +# proxy in front of both. RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs diff --git a/web/app/status/[pageId]/page.tsx b/web/app/status/[pageId]/page.tsx index b53d1e3..3b99d10 100644 --- a/web/app/status/[pageId]/page.tsx +++ b/web/app/status/[pageId]/page.tsx @@ -12,8 +12,21 @@ type FetchResult = | { kind: "not-found" } | { kind: "unavailable" }; -async function fetchSnapshot(host: string, forwardedFor: string, pageId: string): Promise { - const base = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; +/* + * The control plane this call is made to is the visitor's own host. + * + * This app proxies nothing and holds no address for the server (see + * next.config.ts): /public is routed to it by the reverse proxy, exactly as + * /api and /auth are for the browser. So the SSR fetch goes back through that + * proxy at .vantage., which is a per-tenant address by + * construction — and X-Forwarded-Host below is still what selects the tenant, + * because the hop from this process cannot set Host. + */ +function apiBase(proto: string, host: string): string { + return `${proto}://${host}`; +} + +async function fetchSnapshot(base: string, host: string, forwardedFor: string, pageId: string): Promise { // The instance is resolved server-side from the visitor's host, so it has // to be forwarded explicitly — this is a server-to-server call and its own @@ -95,7 +108,8 @@ export default async function PublicStatusPage({ .filter((v): v is string => !!v) .join(", "); - const result = await fetchSnapshot(host, forwardedFor, pageId); + const proto = h.get("x-forwarded-proto")?.split(",")[0].trim() || "https"; + const result = await fetchSnapshot(apiBase(proto, host), host, forwardedFor, pageId); if (result.kind === "not-found") notFound(); return ( diff --git a/web/next.config.ts b/web/next.config.ts index 3b3ed52..5f088be 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,11 +1,21 @@ import type { NextConfig } from "next"; -// Read at runtime, not baked in. The rewrites below run in the Next server -// process, never in the browser, so this never needed the NEXT_PUBLIC_ prefix -// that pins a value into the image at build time. NEXT_PUBLIC_API_URL is still -// honoured so an existing deployment passing it keeps working. -const apiUrl = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; - +/* + * This app proxies nothing. + * + * /api, /auth, /install, /update and /public are the Go server's, and routing + * them there is the reverse proxy's job — the same proxy that already + * terminates TLS in front of this process. Next used to rewrite them itself + * from an API_URL naming the control plane, which meant every deployment had + * two possible request paths for the same URL and one environment variable + * that silently broke a whole prefix when it named the wrong host: pointed at + * the marketing site, /public/status/... 404'd as a page that does not exist, + * indistinguishable from a status page that does not exist. + * + * Browser calls are same-origin and relative (see lib/api.ts), and the public + * status page's server-side fetch is made against the visitor's own host, so + * nothing in this app needs to know the control plane's address any more. + */ const nextConfig: NextConfig = { output: "standalone", async redirects() { @@ -22,40 +32,6 @@ const nextConfig: NextConfig = { }, ]; }, - async rewrites() { - return [ - { - source: "/api/:path*", - destination: `${apiUrl}/api/:path*`, - }, - { - source: "/auth/:path*", - destination: `${apiUrl}/auth/:path*`, - }, - { - source: "/install", - destination: `${apiUrl}/install`, - }, - { - source: "/install.ps1", - destination: `${apiUrl}/install.ps1`, - }, - { - source: "/update", - destination: `${apiUrl}/update`, - }, - { - source: "/update.ps1", - destination: `${apiUrl}/update.ps1`, - }, - { - // The public status page refreshes itself in the browser, so - // the public prefix has to be proxied the same way /api is. - source: "/public/:path*", - destination: `${apiUrl}/public/:path*`, - }, - ]; - }, }; export default nextConfig;