fix: Fixed api url on web
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 7m37s

This commit is contained in:
2026-08-25 13:53:59 +00:00
parent 3e4ccc9720
commit 7a0a1953f6
14 changed files with 109 additions and 83 deletions
+5
View File
@@ -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 \
+4 -2
View File
@@ -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 `<release>-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. |
+1 -1
View File
@@ -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"
+3 -6
View File
@@ -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 . }}.
+10 -9
View File
@@ -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:
+4 -6
View File
@@ -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
+3 -3
View File
@@ -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/
+4 -2
View File
@@ -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:
@@ -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
+17 -5
View File
@@ -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
+12
View File
@@ -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://<your-host>/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 `<your-instance>.vantage.<yourdomain>/status/<page-id>`, 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://<your-instance>.vantage.<yourdomain>/public/status/<page-id>`
— 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
+3 -4
View File
@@ -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
+17 -3
View File
@@ -12,8 +12,21 @@ type FetchResult =
| { kind: "not-found" }
| { kind: "unavailable" };
async function fetchSnapshot(host: string, forwardedFor: string, pageId: string): Promise<FetchResult> {
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 <slug>.vantage.<tld>, 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<FetchResult> {
// 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 (
+16 -40
View File
@@ -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;