Compare commits

...
4 Commits
Author SHA1 Message Date
mrhid6 7a0a1953f6 fix: Fixed api url on web
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 7m37s
2026-08-25 13:53:59 +00:00
mrhid6 3e4ccc9720 feat: Added more debug logging
Chart Release / chart (push) Successful in 16s
Server Deploy / deploy (push) Successful in 7m1s
2026-08-25 13:26:23 +00:00
mrhid6 e5947489e4 fix: Fixed status page published switch 2026-08-25 13:26:10 +00:00
mrhid6 0a7a10aeed feat: Added status pages to license page 2026-08-25 13:10:28 +00:00
19 changed files with 159 additions and 96 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
+28 -7
View File
@@ -2,6 +2,7 @@ package api
import (
"errors"
"log"
"net/http"
"strconv"
"time"
@@ -79,17 +80,24 @@ func RateLimitPublicStatus() gin.HandlerFunc {
// @Failure 404 {object} ErrorResponse
// @Failure 429 {object} ErrorResponse
func getPublicStatusPage(c *gin.Context) {
pageID := c.Param("pageId")
inst, ok := publicStatusInstance(c)
if !ok {
// Every 404 on this route is indistinguishable to the caller by
// design, so the log is the only place the three reasons are told
// apart. It carries no monitor data and no page contents.
log.Printf("public status: 404 page=%q reason=no_instance", pageID)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
snap, err := services.PublicStatusSnapshot(inst.InstanceID, c.Param("pageId"))
snap, err := services.PublicStatusSnapshot(inst.InstanceID, pageID)
if errors.Is(err, services.ErrPageNotFound) {
log.Printf("public status: 404 page=%q instance=%s reason=page_missing_or_unpublished", pageID, inst.InstanceID)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err != nil {
log.Printf("public status: 500 page=%q instance=%s: %v", pageID, inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
@@ -114,21 +122,34 @@ func getPublicStatusPage(c *gin.Context) {
// without this every self-hosted status page 404s forever. More than one is a
// refusal rather than a guess.
func publicStatusInstance(c *gin.Context) (*models.Instance, bool) {
// Host resolution is where this route fails silently: an untrusted peer
// means X-Forwarded-Host is ignored and the request host is the Go
// service's own name, which names no slug. Log the inputs and the branch
// taken, so the 404 says which of the four it was.
host := c.Request.Host
if trustedPeer(c) {
if h := firstForwarded(c.GetHeader("X-Forwarded-Host")); h != "" {
host = h
}
xfh := firstForwarded(c.GetHeader("X-Forwarded-Host"))
trusted := trustedPeer(c)
if trusted && xfh != "" {
host = xfh
}
log.Printf("public status: resolve peer=%s trusted=%t request_host=%q x_forwarded_host=%q using_host=%q slug=%q",
c.RemoteIP(), trusted, c.Request.Host, xfh, host, auth.HostSlug(host))
if inst, ok := auth.InstanceForHost(host); ok {
return inst, true
}
if auth.HostSlug(host) != "" {
if slug := auth.HostSlug(host); slug != "" {
// The host named an instance and that instance does not exist.
log.Printf("public status: no instance for slug=%q (host=%q)", slug, host)
return nil, false
}
if services.DeploymentMode() == license.DeploymentCloud {
log.Printf("public status: host %q names no slug and deployment is cloud, refusing to guess", host)
return nil, false
}
return auth.SoleInstance()
inst, ok := auth.SoleInstance()
if !ok {
log.Printf("public status: host %q names no slug and this deployment has no single instance", host)
}
return inst, ok
}
@@ -405,6 +405,7 @@ func unavailableSnapshot(reason, title string) *StatusSnapshot {
// unpublished" would confirm it exists.
func PublicStatusSnapshot(instanceID, pageID string) (*StatusSnapshot, error) {
if err := ValidatePageID(pageID); err != nil {
log.Printf("public status: page id %q is not a valid id: %v", pageID, err)
return nil, ErrPageNotFound
}
if snap := cachedSnapshot(instanceID, pageID); snap != nil {
@@ -413,9 +414,11 @@ func PublicStatusSnapshot(instanceID, pageID string) (*StatusSnapshot, error) {
page, err := GetStatusPage(instanceID, pageID)
if err != nil {
log.Printf("public status: instance=%s page=%q lookup: %v", instanceID, pageID, err)
return nil, err
}
if !page.Published {
log.Printf("public status: instance=%s page=%q exists but published=false", instanceID, pageID)
return nil, ErrPageNotFound
}
+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
+6 -1
View File
@@ -171,7 +171,11 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
<Keyed label="Instance ID">
<div className="flex items-center gap-2">
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
<button type="button" onClick={copyId} className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary">
<button
type="button"
onClick={copyId}
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
@@ -257,6 +261,7 @@ export default function LicensePage() {
<Feature label="Browser console" included={Boolean(license.features.console)} />
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
<Feature label="Status Pages" included={Boolean(license.features.status_pages)} />
</div>
</Card>
</Group>
+2 -2
View File
@@ -107,10 +107,10 @@ function Switch({ checked, onChange, label }: { checked: boolean; onChange: (v:
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-[21px] w-[38px] flex-shrink-0 rounded-full transition-colors ${checked ? "bg-success" : "bg-border"}`}
className={`relative h-[21px] w-[38px] flex-shrink-0 rounded-full border-0 p-0 transition-colors ${checked ? "bg-success" : "bg-border"}`}
>
<span
className={`absolute top-[2px] h-[17px] w-[17px] rounded-full transition-transform ${
className={`absolute left-0 top-[2px] h-[17px] w-[17px] rounded-full transition-transform ${
checked ? "translate-x-[19px] bg-accent-ink" : "translate-x-[2px] bg-text-tertiary"
}`}
/>
+27 -5
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
@@ -32,17 +45,25 @@ async function fetchSnapshot(host: string, forwardedFor: string, pageId: string)
// intact.
if (forwardedFor) outbound["X-Forwarded-For"] = forwardedFor;
const url = `${base}/public/status/${encodeURIComponent(pageId)}`;
// This call is container-to-container and never appears in the front
// proxy's access log, which is why a 404 here reads as "Next 404'd it".
// Log both halves so the upstream status is visible in the web logs.
console.log(`[status] GET ${url} host=${host} xff=${forwardedFor || "-"}`);
let res: Response;
try {
res = await fetch(`${base}/public/status/${encodeURIComponent(pageId)}`, {
res = await fetch(url, {
headers: outbound,
cache: "no-store",
});
} catch {
} catch (e) {
// The control plane is unreachable. That is not "no such page".
console.error(`[status] upstream unreachable ${url}:`, e);
return { kind: "unavailable" };
}
console.log(`[status] upstream ${res.status} for ${url}`);
if (res.status === 404) return { kind: "not-found" };
// A 429, a 500 or anything else is a page that exists and cannot be read
// right now. Telling a customer mid-outage that their status page does not
@@ -87,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;
File diff suppressed because one or more lines are too long