docs: phase 2 configuration and the reaper's containment
Server Deploy / deploy (push) Successful in 5m8s
Server Deploy / deploy (push) Successful in 5m8s
FREE_INSTANCE_REAP_AFTER is set only in docker-compose.site.yml, so a self-hosted deployment can never reap. Admin and server must carry the same value: one names the deletion date in warnings, the other acts on it. Records that admin now has a second control-plane write path, cloudprov, and that deletion lives in the control plane because that is where the knowledge of what an instance is made of belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ jobs:
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
|
||||
--build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f site/Dockerfile site/
|
||||
docker push "$IMAGE"
|
||||
|
||||
@@ -73,21 +73,20 @@ vantage/
|
||||
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
|
||||
│ ├── assets/ # image sources, not served
|
||||
│ └── Dockerfile # same shape as web/: standalone, node, 3000
|
||||
├── sitesvc/ # public forms: contact mail + signup
|
||||
├── sitesvc/ # public form: contact mail only
|
||||
│ ├── cmd/main.go
|
||||
│ └── internal/
|
||||
│ ├── api/ # contact, signup, verify
|
||||
│ ├── api/ # contact
|
||||
│ ├── mail/ # SMTP
|
||||
│ ├── models/ # mirrors server org/user + pending signup
|
||||
│ ├── provision/ # slug rules mirrored from the control plane
|
||||
│ └── store/ # mongo: pending signups, org/user creation
|
||||
│ └── store/ # Mongo connect helper
|
||||
├── admin/ # licensing authority: the only signer
|
||||
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
|
||||
│ ├── cmd/adminctl/ # staff-add; deliberately has no HTTP surface
|
||||
│ └── internal/
|
||||
│ ├── api/ # customer + staff handlers, route table
|
||||
│ ├── auth/ # staff, cloud-owner and self-hosted sessions
|
||||
│ ├── inject/ # the ONE write path into the control plane
|
||||
│ ├── auth/ # staff, HQ customer and cloud-owner sessions
|
||||
│ ├── inject/ # licence write path into the control plane
|
||||
│ ├── cloudprov/ # instance write path: creates instances + owners
|
||||
│ ├── licensing/ # Issue, LinkInstance, Relink
|
||||
│ ├── mail/ # verification and licence delivery
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
@@ -146,19 +145,19 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
|
||||
|
||||
### Marketing site and sitesvc
|
||||
|
||||
`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`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
|
||||
`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 and listed in admin's `ADMIN_ORIGIN`. 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.
|
||||
|
||||
`sitesvc/` (port `8082`) owns both flows end to end:
|
||||
`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. |
|
||||
| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. |
|
||||
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to the org's sign-in page (`APP_LOGIN_URL` with `{slug}` filled in). |
|
||||
| Form | Endpoint | Effect |
|
||||
| ------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
|
||||
|
||||
All three 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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
@@ -170,22 +169,17 @@ docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
|
||||
|
||||
### Signup and verification
|
||||
|
||||
**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified".
|
||||
Signup is **account-first**: it creates an HQ account and an unverified `customer_user` in admin's own database, nothing in the control plane. Only after a customer later creates a cloud instance from the portal (`POST /api/instances`, see Admin REST API) does an org, or rather an `instance`, come to exist — provisioned by `cloudprov`, with the owner's password hash copied from the HQ user rather than shared. `site_pending_signups` is gone; sitesvc no longer has a signup flow at all.
|
||||
|
||||
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
|
||||
- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing.
|
||||
- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger.
|
||||
- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works.
|
||||
- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users.
|
||||
- Rate limited to 3 signups per client IP per hour, plus a honeypot field.
|
||||
- Links expire after 24 hours (`VerifyWindow`).
|
||||
- An unverified sign-in gets a distinct "check your email" error rather than the generic auth failure, because the address is already known to be theirs.
|
||||
- If sending the verification email fails, the freshly inserted `customer_user` (and account, on first signup) is rolled back rather than left stranded holding the unique index on email.
|
||||
- Rate limited per client IP, plus a honeypot field.
|
||||
|
||||
### The one piece of duplicated logic
|
||||
### Shared provisioning
|
||||
|
||||
`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server.
|
||||
|
||||
**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with.
|
||||
|
||||
sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op.
|
||||
`shared/provision` (`instance.go`, `slug.go`, `user.go`) holds the slug rules, reserved names and instance/user creation logic that both `server` and `admin/internal/cloudprov` need, so there is no longer a second copy to drift: `cloudprov.CreateInstance` calls straight into it to create a control-plane instance and its owner from a customer request.
|
||||
|
||||
---
|
||||
|
||||
@@ -289,6 +283,8 @@ Customer-session (`/api`), every instance resolved through `ownedInstance`:
|
||||
|
||||
```
|
||||
GET /account # account, instances, max_relinks
|
||||
POST /instances # create a cloud instance (Free tier, capped at one per account)
|
||||
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
GET /subscriptions
|
||||
@@ -312,8 +308,6 @@ GET /health/injection
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
|
||||
|
||||
Notes that are not obvious from the structs:
|
||||
|
||||
- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending` → `active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min).
|
||||
@@ -405,15 +399,14 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `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 |
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
|
||||
| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it |
|
||||
| `APP_LOGIN_URL` | no | template for the org sign-in URL a verified owner is redirected to. `{slug}` is replaced with the new org's slug (each org has its own subdomain), e.g. `https://{slug}.vantage.hostxtra.co.uk/login`. A value without `{slug}` is used verbatim; empty means a plain confirmation page. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
|
||||
| `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 |
|
||||
@@ -535,3 +528,4 @@ git push origin main # server + web deploy
|
||||
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
|
||||
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
|
||||
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
|
||||
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
services:
|
||||
# Adds to the base `server` service defined in docker-compose.yml —
|
||||
# only the cloud deployment reaps abandoned Free instances.
|
||||
server:
|
||||
environment:
|
||||
FREE_INSTANCE_REAP_AFTER: "336h"
|
||||
site:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
|
||||
restart: unless-stopped
|
||||
@@ -15,7 +20,6 @@ services:
|
||||
PORT: "8082"
|
||||
MONGO_URI: ${MONGO_URI:-}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
SITE_ORIGIN: ${SITE_ORIGIN:-}
|
||||
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
@@ -45,6 +49,8 @@ services:
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
APP_LOGIN_URL: ${APP_LOGIN_URL:-}
|
||||
FREE_INSTANCE_REAP_AFTER: "336h"
|
||||
|
||||
# The staff and customer console, served at vantage-hq.hostxtra.co.uk.
|
||||
# ADMIN_API_URL is baked into the image at build time, not read here, so
|
||||
|
||||
@@ -22,8 +22,11 @@ COPY . .
|
||||
# SITE_ORIGIN for CORS.
|
||||
ARG NEXT_PUBLIC_SITE_API=""
|
||||
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
|
||||
# Browser-reachable admin URL; site/lib/submit.ts posts account signups here.
|
||||
ARG NEXT_PUBLIC_ADMIN_API_URL=""
|
||||
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
|
||||
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
||||
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
Reference in New Issue
Block a user