From bc6c7cdb5aea324ee62e805e20d43223947bba9d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 28 Jul 2026 16:04:51 +0100 Subject: [PATCH] feat: Updated docker file --- .gitea/workflows/server-deploy.yml | 4 - claude.md | 139 ++++++++++-------- deploy/.env.example | 3 - deploy/docker-compose.yml | 1 - .../2026-07-26-cloud-instance-creation.md | 2 - .../2026-07-26-cloud-instance-identity.md | 1 - server/internal/api/handlers.go | 9 +- server/internal/api/install_ps1.go | 10 +- server/internal/services/dispatch.go | 11 +- 9 files changed, 83 insertions(+), 97 deletions(-) diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml index 5cb54df..e1fefcf 100644 --- a/.gitea/workflows/server-deploy.yml +++ b/.gitea/workflows/server-deploy.yml @@ -162,10 +162,6 @@ jobs: # DOCS_BASE_URL must match the proxy location that routes to # this container and the directory the image serves from. docker build \ - --build-arg DOCS_URL="${{ vars.DOCS_URL }}" \ - --build-arg DOCS_BASE_URL="${{ vars.DOCS_BASE_URL }}" \ - --build-arg APP_URL="${{ vars.APP_URL }}" \ - --build-arg HQ_URL="${{ vars.HQ_URL }}" \ -t "$IMAGE" \ -f docsite/Dockerfile docsite/ docker push "$IMAGE" diff --git a/claude.md b/claude.md index 8486a8f..a32e686 100644 --- a/claude.md +++ b/claude.md @@ -94,6 +94,11 @@ vantage/ │ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit │ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord │ └── lib/ # api client, session guards, formatters +├── docsite/ # user documentation (Docusaurus, static) +│ ├── docs/ # getting-started, vantage, hq, reference, operations +│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-* +│ ├── sidebars.ts # authored by hand, not autogenerated +│ └── nginx.conf # serves the build under /docs ├── shared/ # imported by server, sitesvc and admin │ ├── mail/ # the one email system: transport + tmpl templates │ ├── license/ # payload, sign, verify, trusted keys, plans @@ -147,19 +152,25 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat `site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal. -`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play. +`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately _outside_ `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play. **`ADMIN_ORIGIN` must list every browser origin that calls admin — currently two**: `https://vantage-hq.hostxtra.co.uk` for the console, and `https://vantage.hostxtra.co.uk` because the marketing site's `/start` form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: `cors()` simply omits the `Access-Control-Allow-Origin` header and still answers the preflight `204`, so the browser blocks the request and **admin logs nothing at all**. Symptom is a CORS preflight failure on an endpoint that works fine under curl. `sitesvc/` (port `8082`) now owns only the contact flow: -| Form | Endpoint | Effect | -| ------- | --------------------- | ----------------------------------------------------------------------- | -| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. | +| Form | Endpoint | Effect | +| ------- | ------------------- | ----------------------------------------------------------------------- | +| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. | Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below. -`site`, `sitesvc` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead. +`site`, `sitesvc`, `admin` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead. + +### Documentation site + +`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident. + +Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong. ```bash # self-hosted install — no marketing site, no sitesvc @@ -234,7 +245,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no runtime dependency on admin**. Revoking deletes that row — the control plane has no disabled state, and a row that exists is a row that can sign in. -`instance_members` in admin's database is only admin's *index* of those +`instance_members` in admin's database is only admin's _index_ of those projections; the control-plane row is the access. That is why a failed `instance_members` insert unwinds the projection, and why the boot backfill can rebuild the index from the control plane but never the other way round. @@ -266,7 +277,7 @@ password-change endpoint at all, so there is no competing writer for the hash. - **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `.vantage.` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s. Unique indexes are a **security property**, not an optimisation. `users` is -unique on `(instance_id, email)` — one address is one user *within* an instance, +unique on `(instance_id, email)` — one address is one user _within_ an instance, and the same address may hold a user in several instances, because an account's people are projected into each instance they are granted. This is sufficient only because **every lookup by email is scoped by instance**; there is deliberately no @@ -406,7 +417,7 @@ GET /health/injection · /health/billing Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session. -`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID. +`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID. ## MongoDB Collections @@ -424,7 +435,7 @@ Notes that are not obvious from the structs: - `console_sessions.token_consumed_at` is set atomically to enforce one-time use. - `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence. -Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. +Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. `plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle. @@ -498,32 +509,33 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a ## Environment Variables (server) -| Name | Required | Notes | -| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. | -| `MONGO_URI` | no | default `mongodb://localhost:27017` | -| `MONGO_DB` | no | default `vantage` | -| `REDIS_ADDR` | no | default `localhost:6379` | -| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. | -| `GITEA_HOST` | yes | used to build install scripts and agent download URLs | -| `GUACD_ADDR` | no | default `guacd:4822` | -| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard | -| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written | +| Name | Required | Notes | +| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. | +| `MONGO_URI` | no | default `mongodb://localhost:27017` | +| `MONGO_DB` | no | default `vantage` | +| `REDIS_ADDR` | no | default `localhost:6379` | +| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. | +| `GUACD_ADDR` | no | default `guacd:4822` | +| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard | +| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written | | `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails | **sitesvc** (`deploy/docker-compose.site.yml` only): -| Name | Required | Notes | -| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Name | Required | Notes | +| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. | -| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping | -| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only | -| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS | -| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty | -| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request | -| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter | +| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping | +| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only | +| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS | +| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty | +| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request | +| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter | -`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk. +`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. `LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only. @@ -547,17 +559,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`. -All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on: +All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on: -| App | Ground | Accent | Themes | -| --- | --- | --- | --- | -| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked | -| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark | -| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default | +| App | Ground | Accent | Themes | +| ------------ | -------------------------- | -------------------------------- | --------------------------- | +| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked | +| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark | +| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default | +| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default | -`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. +`adminsite/app/globals.css` and `docsite/src/css/custom.css` hold `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. -There is a **fourth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the three web apps carries a hex. +`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on ``, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token. + +There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex. Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side. @@ -614,7 +629,7 @@ GOOS=linux GOARCH=amd64 go build \ ### `server-deploy.yml` — triggered on every push to `main` -Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`. +Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`. Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host: @@ -625,12 +640,12 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y **Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly: -| Image | Rebuilds when | -| --- | --- | -| `server` | `server/`, `shared/`, `proto/`, `go.work` | -| `admin` | `admin/`, `shared/`, `go.work` | -| `sitesvc` | `sitesvc/`, `shared/`, `go.work` | -| `web` · `site` · `adminsite` | their own directory only | +| Image | Rebuilds when | +| ---------------------------- | ----------------------------------------- | +| `server` | `server/`, `shared/`, `proto/`, `go.work` | +| `admin` | `admin/`, `shared/`, `go.work` | +| `sitesvc` | `sitesvc/`, `shared/`, `go.work` | +| `web` · `site` · `adminsite` · `docsite` | their own directory only | `shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone. @@ -645,23 +660,25 @@ git push origin main # server + web deploy ### Secrets / variables -| Name | Type | Value | -| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` | -| `REGISTRY_USER` | Secret | Gitea username | -| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` | -| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` | -| `DOCKER_HOST` | Variable | registry host used for image tags | -| `API_URL` | Variable | baked into the `web` image at build time | -| `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 | -| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. | -| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. | -| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. | -| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. | -| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. | -| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. | -| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. | +| Name | Type | Value | +| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` | +| `REGISTRY_USER` | Secret | Gitea username | +| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` | +| `DOCKER_HOST` | Variable | registry host used for image tags | +| `API_URL` | Variable | baked into the `web` image at build time | +| `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 | +| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. | +| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. | +| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. | +| `PADDLE_CLIENT_TOKEN` | Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. | +| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. | +| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. | +| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. | +| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. | +| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. | +| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. | --- diff --git a/deploy/.env.example b/deploy/.env.example index 75bd191..04843c9 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -7,9 +7,6 @@ # Must be reachable from managed servers. Use the public host, port 9090. GRPC_HOST=192.168.1.250:9090 -# Gitea host used to build agent install scripts and download URLs. -GITEA_HOST=gitea.hostxtra.co.uk - # 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, # secrets, OIDC secrets, RDP/VNC credentials. # Generate: openssl rand -hex 32 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fed73a5..26e726f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -41,7 +41,6 @@ services: environment: MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage} REDIS_ADDR: redis:6379 - GITEA_HOST: ${GITEA_HOST} GRPC_HOST: ${GRPC_HOST} GRPC_PORT: "9090" HTTP_PORT: "8080" diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md index f29455d..85a7325 100644 --- a/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-creation.md @@ -1830,7 +1830,6 @@ sleep 6 MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \ -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \ -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \ - -e GITEA_HOST=example.invalid \ --add-host host.docker.internal:host-gateway vantage-server:p2 sleep 6 MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper @@ -2012,7 +2011,6 @@ MSYS_NO_PATHCONV=1 docker rm -f p2-server MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \ -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \ -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \ - -e GITEA_HOST=example.invalid -e FREE_INSTANCE_REAP_AFTER=336h \ --add-host host.docker.internal:host-gateway vantage-server:p2 sleep 6 MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md b/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md index 6b99e38..acfdb86 100644 --- a/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-identity.md @@ -780,7 +780,6 @@ Expected: output includes `email_1`. This reproduces a database that predates th MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \ -e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \ -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \ - -e GITEA_HOST=example.invalid \ --add-host host.docker.internal:host-gateway vantage-server:test MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \ diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 4d19f6a..c4300f9 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -139,10 +139,6 @@ func newServer(c *gin.Context) { } services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } host := publicHostFromRequest(c) @@ -392,10 +388,7 @@ func applyUpdates(c *gin.Context) { } func handleUpdateScript(c *gin.Context) { - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } + giteaHost := "gitea.hostxtra.co.uk" script := fmt.Sprintf(`#!/usr/bin/env bash set -euo pipefail diff --git a/server/internal/api/install_ps1.go b/server/internal/api/install_ps1.go index 2a97652..03d8676 100644 --- a/server/internal/api/install_ps1.go +++ b/server/internal/api/install_ps1.go @@ -12,10 +12,7 @@ func handleInstallScriptWindows(c *gin.Context) { serverID := c.Query("server_id") token := c.Query("token") - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } + giteaHost := "gitea.hostxtra.co.uk" grpcHost := os.Getenv("GRPC_HOST") @@ -51,10 +48,7 @@ func handleInstallScriptWindows(c *gin.Context) { } func handleUpdateScriptWindows(c *gin.Context) { - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } + giteaHost := "gitea.hostxtra.co.uk" script := fmt.Sprintf( "#Requires -RunAsAdministrator\n"+ diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index d12a3c7..9b70bcf 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "sync" @@ -80,10 +79,7 @@ type KeyGenParams struct { } func GetLatestAgentVersion() (string, error) { - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } + giteaHost := "gitea.hostxtra.co.uk" url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost) resp, err := http.Get(url) if err != nil { @@ -119,10 +115,7 @@ func DispatchUpdateAgent(serverID string) (string, error) { return "", fmt.Errorf("get latest version: %w", err) } - giteaHost := os.Getenv("GITEA_HOST") - if giteaHost == "" { - giteaHost = "gitea.example.com" - } + giteaHost := "gitea.hostxtra.co.uk" cmdID := uuid.New().String() cmd := &pb.ServerCommand{