Compare commits

...
3 Commits
Author SHA1 Message Date
mrhid6 693d59a3e2 feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s
2026-07-22 16:50:13 +01:00
mrhid6 7a3b8cb700 feat: Updated claude.md 2026-07-22 13:22:26 +01:00
mrhid6 97bc766afb feat: Remove plans 2026-07-22 13:03:19 +01:00
54 changed files with 10914 additions and 3011 deletions
+16
View File
@@ -35,3 +35,19 @@ jobs:
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
-t "$IMAGE" \
-f site/Dockerfile site/
docker push "$IMAGE"
- name: Build and push sitesvc image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
docker push "$IMAGE"
+2 -1
View File
@@ -8,4 +8,5 @@ installer/vantage-agent-windows-amd64.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
.next
*.tsbuildinfo
+339 -240
View File
@@ -1,34 +1,38 @@
# Vantage
A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state.
A self-hosted, multi-tenant infrastructure control plane. It started as SSH key management and has grown into fleet management: SSH key assignment, workflow/script execution, service monitoring, a secrets vault, a browser console (SSH/RDP/VNC), and OS update management.
A central server (Go + Next.js + MongoDB + Redis) drives a lightweight Go agent installed on each managed server. Agents poll over gRPC and also hold a bidirectional command stream for push-style commands.
---
## Architecture Overview
```
┌─────────────────────────────────┐
│ Next.js Frontend
- Upload/manage keys
- Add servers (install script)
│ - Assign/revoke per server │
└────────────┬────────────────────┘
│ REST
┌────────────▼────────────────────┐
Go Backend
- REST API for frontend
- gRPC server for agents
- MongoDB
└────────────────────────────────┘
│ gRPC (TLS)
┌────────────────────────────────┐
│ Go Agent (per server)
- Polls every 30s
- Rewrites authorized_keys │
- Can generate SSH keypairs
└─────────────────────────────────┘
┌──────────────────────────────────────────────
│ Next.js 16 Frontend (web, :3000)
servers · keys · workflows · monitors
secrets · audit · console · settings
└───────────────┬──────────────────────────────┘
│ REST + cookie session
┌───────────────▼──────────────────────────────┐
│ Go Backend (server) │
:8080 REST (gin) :9090 gRPC (agents)
MongoDB (state) · Redis (sessions)
monitor scheduler · workflow runner
guacd tunnel proxy for browser console
└───────────────┬──────────────────────────────┘
│ gRPC (TLS) — outbound from agent only
┌───────────────▼──────────────────────────────┐
│ Go Agent (per server, Linux + Windows)
polls SyncKeys · CommandStream
rewrites authorized_keys (Linux only)
runs workflow steps · monitors · inventory
└──────────────────────────────────────────────
```
Multi-tenancy: every domain document carries `org_id`, and every service query is scoped by it. Org is resolved from the session, and optionally cross-checked against the request host (`<slug>.vantage.<tld>`).
---
## Repository Structure
@@ -36,264 +40,359 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
```
vantage/
├── agent/
│ ├── cmd/main.go
│ ├── cmd/main.go # flags: -generate-key
│ └── internal/
│ ├── config/
│ ├── grpc/
│ ├── keys/
── sync/
│ ├── checker/ # monitor check execution
│ ├── config/ # config.yaml load/save
│ ├── exec/ # workflow step execution
── grpc/ # client + generated pb
│ ├── inventory/ # CPU/mem/disk collection (linux/other)
│ ├── keys/ # authorized_keys read/diff/write
│ ├── monitors/ # agent-run monitor loop
│ ├── sync/ # poll loop + command stream
│ └── updates/ # OS package update check/apply
├── server/
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # REST handlers for Next.js
│ ├── grpc/ # gRPC server implementation
│ ├── models/ # MongoDB models
── services/
├── keys.go
├── servers.go
└── sync.go # builds desired state per server
├── web/
├── app/
│ └── components/
├── proto/
── vantage/v1/vantage.proto
├── deploy/
── docker-compose.yml
│ └── agent.service
└── .gitea/
── workflows/
├── agent-release.yml
└── server-deploy.yml
│ ├── api/ # REST handlers
│ ├── auth/ # local, OIDC, session, middleware, orghost
│ ├── checker/ # server-run monitor checks
── db/ # mongo connect + Col()
├── grpc/ # gRPC server + generated pb
├── models/ # MongoDB documents
├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
└── services/ # business logic + migrations
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
── 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
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact, signup, verify
│ ├── mail/ # SMTP
│ ├── models/ # mirrors server org/user + pending signup
│ ├── provision/ # slug rules mirrored from the control plane
│ └── store/ # mongo: pending signups, org/user creation
├── proto/vantage/v1/vantage.proto
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, agent.service
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
```
---
## Subsystems
### SSH keys
Upload a public key, assign it per server, revoke softly. The agent diffs desired vs on-disk state and rewrites `/root/.ssh/authorized_keys` atomically. Keys can also be generated *on* a server by the agent; the private half can optionally be uploaded and is stored AES-256-GCM encrypted.
### Workflows
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
### Notification channels
Per-org outbound destinations: `webhook`, `smtp`, `discord`, `slack`, `telegram`. Monitors reference channels by ID. Channels are testable from the UI.
### Secrets vault
Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed two ways: referenced by workflow steps via `secret_refs` (injected as env at execution), and read by Kubernetes External Secrets Operator via `GET /api/secrets/:group/values` using a bearer token whose SHA-256 hash is stored in settings.
### Browser console
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
### Inventory and OS updates
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
### 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 `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
`sitesvc/` (port `8082`) owns both flows end to end:
| 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 `APP_LOGIN_URL`. |
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.
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# vantage.sh — control plane plus the public site
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".
- 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.
### The one piece of duplicated logic
`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.
---
## Auth and Orgs
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
- **OIDC** — configured *per org* (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start``/auth/oidc/callback`.
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
Unique indexes on user email and org slug are a **security property**, not an optimisation: `GetUserByEmail` does an unscoped `FindOne`, so duplicates would let the OIDC cross-org guard compare against an arbitrary user. Same for duplicate settings docs and duplicate ESO token hashes.
---
## gRPC API
```protobuf
syntax = "proto3";
package vantage.v1;
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
}
message RegisterRequest {
string server_id = 1;
string pre_reg_token = 2;
string hostname = 3;
string ip_address = 4;
string os_info = 5;
}
message RegisterResponse {
string agent_token = 1;
}
message SyncRequest {
string server_id = 1;
string agent_token = 2;
}
message SyncResponse {
repeated string public_keys = 1; // full authorized_keys lines
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
}
message UploadKeyResponse {
string key_id = 1;
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
```
No streaming — polling only. Poll interval: **30 seconds**.
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
---
## REST API
Unauthenticated:
```
GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /api/secrets/:group/values # bearer token (ESO)
```
Session-authed under `/api`:
```
servers GET,POST /servers · GET,POST /servers/new · GET,DELETE /servers/:id
POST /servers/:id/{generate-key,update-agent,apply-updates}
keys GET,POST /keys · GET,DELETE /keys/:id · GET /keys/:id/private-key
POST /keys/:id/assign · DELETE /keys/:id/assign/:serverId
workflows GET,POST /steps · PUT,DELETE /steps/:id · GET /steps/:id/export
POST /steps/{import,seed-defaults,parse} · GET /steps/usage
GET,POST /workflows · GET,PUT,DELETE /workflows/:id
POST /workflows/:id/run · GET /workflows/:id/runs
GET /runs/:runId · POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs[/stream]
monitors GET,POST /monitors · GET,PUT,DELETE /monitors/:id
GET /monitors/:id/{incidents,uptime}
channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:id/test
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
console POST /console/connect · GET /console/tunnel (websocket)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
```
---
## MongoDB Collections
### `servers`
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
```json
{
"_id": "ObjectId",
"server_id": "uuid",
"hostname": "proxmox-node-1",
"ip_address": "10.10.10.5",
"os_info": "Ubuntu 24.04",
"pre_reg_token": "abc123",
"pre_reg_expires": "ISODate",
"agent_token_hash": "sha256...",
"status": "pending|active|offline",
"last_seen": "ISODate",
"created_at": "ISODate"
}
```
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
- `pre_reg_token` is cleared after the agent successfully calls `Register()`
- `agent_token_hash` stores SHA-256 of the token — never plaintext
- `status` transitions: `pending``active` on first `Register()`, `offline` if last_seen exceeds threshold
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
### `keys`
Notes that are not obvious from the structs:
```json
{
"_id": "ObjectId",
"key_id": "uuid",
"label": "dom-macbook",
"public_key": "ssh-ed25519 AAAA...",
"fingerprint": "SHA256:...",
"source": "uploaded|generated",
"generated_by_server_id": "uuid",
"created_at": "ISODate"
}
```
- `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).
- `servers.inventory` holds the latest metrics snapshot with separate `metrics_at` / `static_at` timestamps.
- `keys.private_key_enc` and `passphrase_enc` are AES-256-GCM; the JSON form exposes only `has_private_key` / `has_passphrase`.
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
### `assignments`
### Migrations
```json
{
"_id": "ObjectId",
"key_id": "uuid",
"server_id": "uuid",
"assigned_at": "ISODate",
"revoked_at": "ISODate | null"
}
```
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
- `revoked_at: null` = key is active on that server
- Revocation is soft — set `revoked_at`, agent picks it up on next poll
- `0001_default_org_backfill`
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
- `0003_missed_org_scopes`
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
---
## Agent Lifecycle
### Config file — `/etc/vantage/config.yaml`
### Config file
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`.
```yaml
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after first successful Register()
agent_token: "" # written by agent after Register()
pre_reg_token: "<token>" # removed after first successful Register()
agent_token: "" # written by agent after Register()
poll_interval: 30s
tls: true
```
Config file permissions: `0600`. Config directory: `0700`.
### Startup flow
### Startup
```
1. Load config
2. If pre_reg_token present:
→ call Register(server_id, pre_reg_token, hostname, ip, os_info)
→ save returned agent_token to config
→ delete pre_reg_token from config
3. Enter poll loop
2. If pre_reg_token present → Register() → save agent_token, clear pre_reg_token, reconnect
3. Start goroutines: command stream · update check (hourly) · inventory · monitors
4. Enter SyncKeys poll loop (default 30s)
```
### Poll loop (every 30s)
### Poll loop
```
1. Call SyncKeys(server_id, agent_token)
2. Receive []public_keys
3. Compute fingerprints of current /root/.ssh/authorized_keys
4. If state unchanged → skip write
5. If changed:
→ write to /root/.ssh/authorized_keys.tmp
→ os.Rename() to /root/.ssh/authorized_keys (atomic)
→ chmod 0600
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
### Key generation (on demand)
### Install
- Triggered by a flag or API call from the server
- Runs `ssh-keygen` via `exec.Command`
- Uploads public key via `UploadGeneratedKey()`
- Private key stays local on the machine
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
```ini
[Unit]
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
```
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, runs as root.
Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent as a service via NSSM.
---
## Server Registration Flow
1. Click **Add Server** in the UI
2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id`
3. UI displays a one-liner install command with copy button:
```bash
curl -fsSL https://vantage.yourdomain.com/install | \
bash -s -- --server-id=<id> --token=<token>
```
4. Install script:
- Detects arch (`amd64` / `arm64`)
- Downloads agent binary from Gitea release
- Verifies SHA-256 checksum
- Writes `/etc/vantage/config.yaml`
- Installs and starts systemd unit
5. On first `SyncKeys` call, server marks status as `active`
1. **Add Server** in the UI calls `POST /api/servers/new`, which generates a `server_id` and a pre-registration token (TTL 1 hour, single-use).
2. The UI shows a one-liner:
```bash
curl -fsSL https://vantage.yourdomain.com/install | \
bash -s -- --server-id=<id> --token=<token>
```
Windows gets the `/install.ps1` equivalent.
3. The script detects arch, downloads the agent from the Gitea release, verifies the SHA-256 checksum, writes the config, installs and starts the service.
4. The server flips to `active` on first sync.
The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag.
`/install` is served dynamically, injecting the latest agent version from the Gitea API.
---
## 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 |
**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 | where a verified owner is sent to sign in; without it they get a plain confirmation page |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (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 the public marketing site on `3001` and is only used on vantage.sh.
---
## Security
- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent)
- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`)
- Server stores `SHA-256(agent_token)` — never the plaintext token
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
- `authorized_keys` written as `0600`, owned by root
- Pre-registration tokens are short-lived (1 hour) and single-use
- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes)
- gRPC over TLS; agents connect outbound only, no inbound firewall holes on managed servers.
- Per-server agent token stored as SHA-256 on the server, plaintext only in the agent's `0600` config.
- Pre-registration tokens are short-lived (1 hour) and single-use.
- AES-256-GCM at rest for private keys, key passphrases, vault secrets, OIDC client secrets, RDP/VNC credentials.
- Console session tokens are one-time; RDP credentials are consumed on tunnel open.
- ESO read token stored as a SHA-256 hash and rotatable.
- Unique indexes on user email, org slug, settings org, and ESO token hash are load-bearing for tenant isolation.
- `authorized_keys` written `0600`, owned by root. The agent runs as root because it must.
- Every mutating API path writes an audit event.
---
## Frontend Routes
## Frontend
| Route | Purpose |
| --------------- | -------------------------------------------------------------------- |
| `/servers` | List all servers, online/offline status badge, last seen timestamp |
| `/servers/new` | Displays the one-liner install script with copy button |
| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server |
| `/keys` | All keys — label, fingerprint, source, assigned count |
| `/keys/[id]` | Assign key to servers, revoke per server |
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
| Route | Purpose |
| --- | --- |
| `/setup` | First-run bootstrap: create the first org and owner |
| `/login` | Local or OIDC sign-in |
| `/` | Fleet dashboard |
| `/servers`, `/servers/new`, `/servers/[id]` | Fleet list, install one-liner, server detail (keys, inventory, updates) |
| `/servers/[id]/console` | Browser SSH/RDP/VNC session |
| `/keys`, `/keys/[id]` | Key library; assign and revoke per server |
| `/workflows`, `/workflows/[id]`, `/workflows/[id]/runs[/runId]` | Compose, run, and follow live logs |
| `/steps` | Reusable step library |
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
| `/secrets`, `/secrets/[group]` | Vault |
| `/audit` | Audit log |
| `/settings`, `/settings/org`, `/settings/notifications` | Alerts, members, OIDC, channels |
---
## CI/CD — Gitea Actions
### Agent release — `.gitea/workflows/agent-release.yml`
### `agent-release.yml` — triggered by `agent/v*` tags
Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums.
```yaml
on:
push:
tags:
- "agent/v*"
```
Build command:
Builds `linux/amd64`, `linux/arm64`, `windows/amd64`, writes `checksums.txt`, creates a Gitea release. A second `msi` job on `windows-2022` packages the WiX installer.
```bash
GOOS=linux GOARCH=amd64 go build \
@@ -301,51 +400,51 @@ GOOS=linux GOARCH=amd64 go build \
-o dist/vantage-agent-linux-amd64 ./cmd
```
Release assets:
### `server-deploy.yml` — triggered on every push to `main`
- `vantage-agent-linux-amd64`
- `vantage-agent-linux-arm64`
- `checksums.txt`
Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`.
### Server deploy — `.gitea/workflows/server-deploy.yml`
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
Note that despite the name, **this workflow does not deploy**it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host:
```bash
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
```
### Tagging convention
### Tagging
```bash
# Release a new agent version
git tag agent/v1.0.0 && git push origin agent/v1.0.0
# Server + web deploy automatically on push to main
git push origin main
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
git push origin main # server + web deploy
```
### Required Gitea secrets / variables
### Secrets / variables
| Name | Type | Value |
| ------------------- | -------- | ------------------------------------------ |
| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope |
| `DEPLOY_HOST` | Secret | IP/hostname of the server VM |
| `DEPLOY_USER` | Secret | SSH user for deploy |
| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH |
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
| 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 | sitesvc base URL, baked into the `site` image (contact form) |
| `SITE_CONTACT_EMAIL` | Variable | optional; mailto fallback address |
---
## Design Decisions
- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed
- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation
- **Outbound-only agent connections** — no inbound firewall holes required on managed servers
- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes
- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state
- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history
- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management
- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time
- **gRPC for agent traffic** — strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
- **Outbound-only agents** — no inbound ports on managed servers, works behind NAT.
- **Poll for keys, push for commands** — a 30s key poll is fine, but running a workflow step should not wait up to 30s.
- **Atomic `authorized_keys` rewrite** — temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file.
- **Fingerprint diffing before write** — no disk churn on unchanged state.
- **Soft revocation** — `revoked_at` rather than deletes; preserves audit history.
- **Run snapshots** — workflow runs freeze their resolved steps so editing a step never rewrites past runs.
- **Monitors run in two places** — server-side for external endpoints, agent-side for anything only reachable from inside the target network.
- **Redis for sessions only** — all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
- **`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.
+48
View File
@@ -0,0 +1,48 @@
# Public marketing site and its backend. Deliberately kept out of
# docker-compose.yml so a self-hosted install never runs either of them:
#
# self-hosted: docker compose up -d
# vantage.sh: docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
#
# sitesvc owns both public forms end to end. It shares MongoDB with the control
# plane — that is how a new tenant becomes visible to the app — but shares no
# code and no process with it. The control plane has no public signup endpoint.
services:
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
ports:
- 3001:3000
depends_on:
- sitesvc
sitesvc:
image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest
restart: unless-stopped
ports:
- 8082:8082
environment:
PORT: "8082"
# Must point at the same database the control plane uses, or the app
# will not see organisations created here. The database name comes
# from the URI path:
# mongodb://user:pass@host:27017/vantage?authSource=vantage
# A URI with no database is refused at boot rather than defaulted.
MONGO_URI: ${MONGO_URI:-}
# Public base URL of this service. Verification links are built from
# it, so an unset or wrong value produces links that go nowhere.
PUBLIC_URL: ${SITE_PUBLIC_URL:-}
# Where a verified owner is sent to sign in.
APP_LOGIN_URL: ${SITE_APP_LOGIN_URL:-}
# Origins allowed to POST the forms. Unset means every cross-origin
# browser request is refused.
SITE_ORIGIN: ${SITE_ORIGIN:-}
# Only enable behind a proxy that overwrites X-Forwarded-For;
# otherwise clients can spoof their way past the rate limiter.
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
SMTP_HOST: ${SITE_SMTP_HOST:-}
SMTP_PORT: ${SITE_SMTP_PORT:-587}
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
SMTP_FROM: ${SITE_SMTP_FROM:-}
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,158 +0,0 @@
# SaaS: Auth + Organizations — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
---
## 1. Summary
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit logs, monitors, notification channels, console sessions, incidents, uptime rollups) carries an `org_id` and all queries are scoped to the caller's org.
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
No billing, no seat/server limits this iteration (schema leaves room).
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
| gRPC endpoint | Single shared `grpc-vantage.hostxtra.co.uk` — no per-org subdomain. Org resolved from `server_id`/token, never from host. Agent configs unchanged. |
---
## 3. Data model
### `orgs`
```json
{ "_id":"ObjectId", "org_id":"uuid", "name":"Doms Org", "slug":"doms-org", "created_at":"ISODate" }
```
- `slug` derived from `name` at creation: lowercase, spaces/underscores → `-`, strip non `[a-z0-9-]`, collapse repeat `-`, trim leading/trailing `-`. `Doms Org``doms-org`.
- **Unique index on `slug`** (global). On collision append `-2`, `-3`, … or reject and ask user to pick.
- Length 340. Reserved slugs blocked: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, plus the bare apex.
- Slug is the DNS label → `doms-org.vantage.hostxtra.co.uk`. Treat as **immutable in v1** (rename breaks bookmarks, cookies, OIDC redirect URLs). Renaming deferred.
### `users`
```json
{
"_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
"email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
"auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
}
```
Unique index on `email` (global — email identifies the account and its org).
### `org_oidc` (per-org provider config)
```json
{
"_id":"ObjectId", "org_id":"uuid",
"issuer":"https://id.acme.com", "client_id":"...",
"client_secret_enc":"AES...", // encrypted with existing crypto.go
"enabled": true, "updated_at":"ISODate"
}
```
### Existing collections — add `org_id`
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels`, `console_sessions`, `incidents`, `monitor_rollups` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
> The audit collection is named `audit_logs` and the notification channel collection `notification_channels`. Earlier drafts of this document called them `audit` and `channels`; those names were copied verbatim into the migration's scoped-collection list and silently skipped both collections. Use the real names.
---
## 4. Auth flows
### Local
- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
- `POST /auth/logout` — destroy session.
- `GET /auth/me` — returns current user + org.
### Org-admin user management
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
### Per-org OIDC
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted. UI shows the exact redirect URL the admin must register with their provider: `https://<slug>.vantage.hostxtra.co.uk/auth/oidc/callback`.
- `GET /auth/oidc/start` — org resolved from host (subdomain slug). Look up org's `org_oidc`, build provider on demand (cache per org). Redirect URL **derived from host** (`https://<host>/auth/oidc/callback`), not stored. State carries `org_id`. The per-org provider cache is evicted when the org's OIDC config is saved, so a rotated issuer takes effect without a restart; the oauth2 config is built per request (its redirect URL is host-derived) and never cached.
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
### First-run bootstrap
- `GET /auth/bootstrap-status``{ needs_setup: bool }` (true when `users` is empty).
- Setup page collects org name + owner email/password → creates org + owner → session.
---
## 5. Request scoping
- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
### Host-based org resolution (per-org subdomain)
- Wildcard DNS `*.vantage.hostxtra.co.uk` + wildcard TLS cert (Let's Encrypt DNS-01). One record, one cert, no per-org ops.
- Middleware extracts subdomain label from `Host` header → look up `orgs.slug` → org. Cache slug→org_id (hits only; misses are never cached so a freshly bootstrapped org resolves immediately). The app root label (the `vantage` in `<slug>.vantage.<tld>`) is read from `APP_ROOT_LABEL`, defaulting to `vantage` — deployments on another root must set it or no host resolves to an org.
- **Hostname is a routing/UX hint, NOT an authorization boundary.** Authorization stays session `org_id` (spec §9). If session org ≠ host org → reject (or redirect to correct host). Never trust `Host` to grant access.
- `/auth/oidc/start` reads org from host — drops the "type your org" box.
- Session cookie set on the **exact host** (`doms-org.vantage...`), not parent `.vantage...`, so cookies don't leak across orgs.
- Apex `vantage.hostxtra.co.uk` (no subdomain): serves bootstrap + login-by-email fallback; after login redirect to the user's org host.
---
## 6. Removing global Authentik
- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
---
## 7. Migration
One-shot migration run at startup (idempotent):
1. If any scoped collection contains documents without `org_id`: reuse the org with slug `default`, creating it ("Default") if absent. Note this is looser than "`orgs` is empty AND ..." — the implementation is the authoritative and safer form, since an instance can have an org already (created by first-run bootstrap) while legacy documents still lack `org_id`; the stricter condition would skip the backfill and strand that data.
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels` documents missing it.
- `console_sessions`, `incidents` and `monitor_rollups` are also scoped, but their org is derived from the owning `servers`/`monitors` record rather than defaulted (migration `0003`), since defaulting them would mix one org's console history and incident timeline into another's.
- Migration `0003` also re-runs step 2 for `audit_logs` and `notification_channels`: the original `0001` listed them under the wrong names (`audit`, `channels`) and wrote its marker regardless, so those documents need a second, separately-markered pass to converge.
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
---
## 8. Frontend
- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
---
## 9. Security
- Passwords: bcrypt (cost ≥ 12). Never returned.
- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
- Role checks on all org-admin mutations.
---
## 10. Out of scope
- Billing, plans, seat/server limits.
- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
- SCIM / directory sync, SAML.
- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
- Tests (skipped, consistent with prior iterations).
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
out
.env*
npm-debug.log*
+48
View File
@@ -0,0 +1,48 @@
# Dependencies stage
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client
# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty
# and contact falls back to mailto while signup reports it is unavailable.
ARG NEXT_PUBLIC_SITE_API=""
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
RUN npm run build
# Runtime stage
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description:
"Vantage began as a weekend fix for a lost laptop and grew into a fleet control plane. How it is built, and what it deliberately does not do.",
};
export default function AboutPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>
Built for the fleet nobody was given a budget to manage.
</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage started as a weekend fix for a bad afternoon. A laptop was lost, and finding every server that
trusted its key meant SSHing into each one with a text editor open. The list lived in someone&apos;s head.
Two of the boxes were not on it.
</p>
<p>
The obvious tools were all heavier than the problem. A configuration management stack to write one file. A
bastion host that becomes the thing you now have to keep alive. A certificate authority with a rotation
story nobody wanted to own.
</p>
<p>
So it began with one job done properly: hold <code>authorized_keys</code> to a known state. Then the same
agent turned out to be the right place to run a deploy script, check whether a service was answering, and
open a shell when something was on fire. Each addition had to earn its place by riding the connection that
already existed.
</p>
<p>
Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need
to prove who can reach them.
</p>
</div>
<div>
<span className="tag">How it is built</span>
<div className="specs">
<div className="spec">
<span className="spec__k">SERVER</span>
<div>
<h3>Go, MongoDB, Redis</h3>
<p>
One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable;
Redis holds sessions and nothing else.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">WEB</span>
<div>
<h3>Next.js</h3>
<p>
An operations interface, not a brochure: dense tables, live log streams, and state you can read at a
glance.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">AGENT</span>
<div>
<h3>Go, Linux and Windows</h3>
<p>
A single static binary under systemd or as a Windows service. No runtime, no dependencies, no
package manager involved.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CONSOLE</span>
<div>
<h3>Guacamole</h3>
<p>
Protocol handling is a solved problem. We proxy the connection and manage the credentials around it.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Security posture</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The parts worth being specific about.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Tokens</span>
<h3>Hashed, never stored plain</h3>
<p>
Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the
agent&apos;s own disk at 0600 and nowhere else.
</p>
</article>
<article className="cap">
<span className="cap__k">At rest</span>
<h3>AES-256-GCM</h3>
<p>
Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted
with a key held only by your deployment.
</p>
</article>
<article className="cap">
<span className="cap__k">One-time</span>
<h3>Tokens that expire and spend</h3>
<p>
Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the
tunnel opens.
</p>
</article>
<article className="cap">
<span className="cap__k">Recorded</span>
<h3>Every mutation is audited</h3>
<p>
Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and
kept.
</p>
</article>
</div>
</section>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
};
const CHANNELS = [
{
title: "Support",
body: "Everything else, including anything urgent.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk",
},
{
title: "Security disclosure",
body: "Encrypted reports, acknowledged within 72 hours.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
link: "git.vantage.sh/vantage",
href: "https://git.vantage.sh/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
link: "status.vantage.sh",
href: "https://status.vantage.sh",
},
];
export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Tell us what your fleet looks like.
</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
</div>
))}
</div>
</div>
</section>
);
}
+1184
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import "./globals.css";
import { Footer } from "@/components/Footer";
import { Nav } from "@/components/Nav";
import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.sh"),
title: {
default: "Vantage — one control plane for the whole fleet",
template: "%s — Vantage",
},
description:
"Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage — one control plane for the whole fleet",
description:
"Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { InstrumentPanel } from "@/components/InstrumentPanel";
export default function OverviewPage() {
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every
machine you manage. One agent per server, outbound connections only, all state in your own database.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
</section>
</div>
<InstrumentPanel />
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
Six tools, six sources of truth, one afternoon lost.
</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks
in a separate service, secrets in a chat thread, and no record of who ran what. None of those systems know
about each other, so every question who can reach this box, what ran on it last, is it even up gets
answered by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>
Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is
not the answer.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>
Assignments, revocations, runs, console sessions and settings changes all land in the same log,
attributed to a person.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and
rewrites <code>authorized_keys</code> atomically.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Execution</span>
<h3>Workflows</h3>
<p>
A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into
workflows that target a set of servers.
</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>
HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target
network.
</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Secrets</span>
<h3>Vault</h3>
<p>
Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never
written to logs.
</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>
Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is
recorded in the audit log.
</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>
CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can
apply from the interface.
</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
</ul>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>
Four steps, and the first three take a minute.
</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an organisation</h3>
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
</div>
</div>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.sh/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.sh/install.ps1 | <b>iex</b>
</pre>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>
Create an organisation, install one agent, and watch a key land on a real box.
</p>
</div>
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</div>
</section>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Platform",
description:
"How Vantage fits together: a control plane you run, one agent per server, and a single outbound connection between them.",
};
export default function PlatformPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">Platform</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>
How the pieces fit together.
</h1>
<p className="lede">
Three moving parts: a control plane you run, an agent on each server, and one outbound connection between
them.
</p>
<div className="split" style={{ marginTop: "3rem" }}>
<div>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>The agent never listens.</h2>
<div className="prose" style={{ marginTop: "1rem" }}>
<p>
Every agent dials out to the control plane over gRPC with TLS. Nothing needs an inbound port, nothing
needs a static address, and a machine behind NAT is no different from one with a public IP.
</p>
<p>
Key state is polled on a 30-second interval, because 30 seconds is fine for access control and polling
is simple to reason about. Everything that should not wait running a step, opening a console, applying
updates is pushed down a bidirectional command stream the agent holds open.
</p>
</div>
</div>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">POLL</span>
<div>
<h3>SyncKeys, every 30s</h3>
<p>The desired key set for this server. Unchanged state means no disk write at all.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">PUSH</span>
<div>
<h3>Command stream</h3>
<p>Generate a key, run a step, apply updates, update the agent, clean up a workspace.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REPORT</span>
<div>
<h3>Inventory and checks</h3>
<p>
Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they
complete.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Write path</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The file is never half-written.
</h2>
<div className="split split--even" style={{ marginTop: "2rem" }}>
<p className="prose">
The agent computes the desired <code>authorized_keys</code> content, compares it to what is on disk, and
stops there if nothing changed. When it does need to write, it writes a temporary file in the same directory
and renames it over the real one. A machine that loses power mid-write keeps the file it had.
</p>
<pre className="code">
<i>// agent poll, simplified</i>
{"\n"}
desired := client.SyncKeys(serverID, token){"\n"}
current := keys.ReadAuthorizedKeys(){"\n\n"}
<b>if</b> !keys.StateChanged(current, desired) {"{"}
{"\n "}
<i>// nothing to do</i>
{"\n "}
<b>return</b> nil{"\n"}
{"}"}
{"\n\n"}
keys.WriteAuthorizedKeys(desired){"\n"}
<i>// write .tmp, os.Rename(), chmod 0600</i>
</pre>
</div>
</section>
<section className="rail band">
<span className="tag">Tenancy and identity</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>
Organisations are the boundary.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Isolation</span>
<h3>Scoped at the query</h3>
<p>
Every server, key, workflow, monitor and secret belongs to an organisation, and every lookup is filtered
by it. Uniqueness constraints are enforced by the database, not by application logic.
</p>
</article>
<article className="cap">
<span className="cap__k">Roles</span>
<h3>Owner, admin, member</h3>
<p>
Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.
</p>
</article>
<article className="cap">
<span className="cap__k">Identity</span>
<h3>Local or OIDC, per organisation</h3>
<p>
Sign in with email and password, or connect your own provider. Each organisation configures its own issuer
and client.
</p>
</article>
<article className="cap">
<span className="cap__k">Sessions</span>
<h3>Server-side, 24 hours</h3>
<p>
Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs
everyone out and costs no durable data.
</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">What we do not build</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>The scope is the feature.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">NOT A PROXY</span>
<div>
<h3>We are never in the SSH path</h3>
<p>
Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH
still works.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO CUSTODY</span>
<div>
<h3>Private keys stay put by default</h3>
<p>
Keys generated on a server stay on it unless you explicitly upload the private half, and anything stored
is encrypted with a key only your deployment holds.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PER-USER</span>
<div>
<h3>Root, not every account</h3>
<p>
Vantage manages one file per server. Per-user key management is a different product with a different
failure mode.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PLUGINS</span>
<div>
<h3>An agent you can read in an evening</h3>
<p>
A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that
runs as root.
</p>
</div>
</div>
</div>
</section>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
"Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
};
const COMPARISON: [string, string, string, string][] = [
["Managed servers", "3", "Unlimited", "Unlimited"],
["Members", "1", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
["Monitors", "3", "Unlimited", "Unlimited"],
["Secrets vault", "—", "Yes", "Yes"],
["Browser console", "—", "Yes", "Yes"],
["OIDC single sign-on", "—", "Yes", "Yes"],
["Audit history", "30 days", "Forever", "Forever"],
["Runs on your hardware", "—", "—", "Yes"],
["Support", "Community", "Next business day", "Priority"],
];
export default function PricingPage() {
return (
<section className="rail band band--open">
<span className="tag">Pricing</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "17ch" }}>
Per managed server. Nothing else counts.
</h1>
<p className="lede">
People are free. Keys, workflows, monitors and secrets are free. You pay for servers running an agent, because
that is the only number that grows with you.
</p>
<div className="plans">
<div className="plan">
<div>
<div className="plan__n">Solo</div>
<p className="plan__d">A homelab, a couple of VPSes, and the keys on your own laptop.</p>
</div>
<div className="plan__p">
£0 <span>forever</span>
</div>
<ul>
<li>Up to 3 servers</li>
<li>Keys, workflows and monitors</li>
<li>One member, one organisation</li>
<li>Community support</li>
</ul>
<Link className="btn btn--line" href="/start">
Create organisation
</Link>
</div>
<div className="plan plan--pick">
<div>
<div className="plan__n">Fleet</div>
<p className="plan__d">Real infrastructure, and more than one person holding the keys.</p>
</div>
<div className="plan__p">
£4 <span>/ server / month</span>
</div>
<ul>
<li>Unlimited servers and members</li>
<li>Owner, admin and member roles</li>
<li>OIDC single sign-on</li>
<li>Browser console and secrets vault</li>
<li>Full audit history</li>
<li>Email support, next business day</li>
</ul>
<Link className="btn btn--solid" href="/start">
Start 14-day trial
</Link>
</div>
<div className="plan">
<div>
<div className="plan__n">Self-hosted</div>
<p className="plan__d">The whole stack on your metal, behind your own boundary.</p>
</div>
<div className="plan__p">
£290 <span>/ year, per install</span>
</div>
<ul>
<li>Everything in Fleet, no server cap</li>
<li>Your MongoDB, Redis and certificates</li>
<li>Mirror agent releases internally</li>
<li>Priority support and upgrade notes</li>
</ul>
<Link className="btn btn--line" href="/contact">
Talk to us
</Link>
</div>
</div>
<div className="scroll">
<table className="cmp">
<thead>
<tr>
<th>Capability</th>
<th>Solo</th>
<th>Fleet</th>
<th>Self-hosted</th>
</tr>
</thead>
<tbody>
{COMPARISON.map(([capability, solo, fleet, selfHosted]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{solo}</td>
<td>{fleet}</td>
<td>{selfHosted}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: "3.2rem", maxWidth: "66ch" }}>
<span className="tag">Fine print, in plain words</span>
<div className="specs">
<div className="spec">
<span className="spec__k">COUNTING</span>
<div>
<h3>What counts as a server</h3>
<p>One running agent, one server. Remove a box and it stops billing that day.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">LIMITS</span>
<div>
<h3>Going over on Solo</h3>
<p>
Nothing is deleted. A fourth agent registers and heartbeats, but stops syncing keys until you upgrade or
remove a server.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">EXIT</span>
<div>
<h3>Leaving</h3>
<p>
Export every server, key, workflow and secret group as JSON whenever you like. Agents keep their last
synced state on disk, so nobody is locked out mid-migration.
</p>
</div>
</div>
</div>
</div>
</section>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { OrgForm } from "@/components/OrgForm";
export const metadata: Metadata = {
title: "Create organisation",
description:
"An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
};
export default function StartPage() {
return (
<section className="rail band band--open">
<div className="split">
<div>
<span className="tag">Create organisation</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Set up your organisation.
</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any
other organisation. Confirm your email and it is created with you as its owner.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
<OrgForm />
</div>
</div>
<div>
<span className="tag">What happens next</span>
<div className="specs">
<div className="spec">
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>
We send a link that works once. Your organisation is created when you open it, not before.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key</h3>
<p>
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses
duplicates.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a server</h3>
<p>Run the install command as root. It expires in an hour and works once.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it register</h3>
<p>The server moves from pending to active on first sync, usually inside 30 seconds.</p>
</div>
</div>
</div>
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.sh/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
</div>
</section>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitContact, type SubmitResult } from "@/lib/submit";
export function ContactForm() {
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitContact({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
servers: String(data.get("servers") ?? ""),
topic: String(data.get("topic") ?? ""),
message: String(data.get("message") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Message sent.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We reply within one business day. If it is urgent, email support@hostxtra.co.uk directly.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="c-name">Your name</label>
<input id="c-name" name="name" type="text" autoComplete="name" required aria-describedby="c-name-err" />
{fieldError("name") && (
<small id="c-name-err" className="field__err">
{fieldError("name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-email">Work email</label>
<input id="c-email" name="email" type="email" autoComplete="email" required aria-describedby="c-email-err" />
{fieldError("email") && (
<small id="c-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="c-servers">Roughly how many servers?</label>
<select id="c-servers" name="servers" defaultValue="425">
<option>13</option>
<option>425</option>
<option>26100</option>
<option>More than 100</option>
</select>
</div>
<div className="field">
<label htmlFor="c-topic">What is this about?</label>
<select id="c-topic" name="topic" defaultValue="Evaluating Vantage">
<option>Evaluating Vantage</option>
<option>Self-hosted licensing</option>
<option>Migrating from something else</option>
<option>Security disclosure</option>
</select>
</div>
<div className="field">
<label htmlFor="c-message">What are you trying to solve?</label>
<textarea
id="c-message"
name="message"
required
placeholder="We inherit client servers and can never prove who still has access…"
aria-describedby="c-message-err"
/>
{fieldError("message") && (
<small id="c-message-err" className="field__err">
{fieldError("message")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Send message"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import { NAV_LINKS } from "@/components/nav-links";
export function Footer() {
return (
<footer className="foot">
<div className="rail foot__in">
<p>Vantage self-hosted fleet control for people who own their servers.</p>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
</Link>
))}
<Link href="/start">Create organisation</Link>
</div>
</footer>
);
}
+14
View File
@@ -0,0 +1,14 @@
/*
* A field no person ever sees or tabs into, but an automated form-filler will
* happily complete. The server treats any value here as a bot. Hidden with
* inline styles rather than a utility class so it stays hidden even if the
* stylesheet fails to load.
*/
export function Honeypot() {
return (
<div style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }} aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" defaultValue="" />
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
/*
* The hero's signature element: a fleet panel that plays one honest cycle of
* what the product actually does — a workflow runs three steps, a TLS monitor
* fails and opens an incident, a key revocation lands — then rests. It is a
* dramatisation, not live data, so nothing here talks to an API.
*/
type LogLine = { time: string; body: React.ReactNode };
type Beat = {
at: number;
line: LogLine;
effect?: "incident" | "runDone" | "revoked";
};
const BEATS: Beat[] = [
{ at: 600, line: { time: "14:22:02", body: "running · step 1/3 · pull image" } },
{ at: 1500, line: { time: "14:22:04", body: "running · step 2/3 · migrate database" } },
{
at: 2600,
line: { time: "14:22:07", body: <><span className="ok">ok</span> · migrate database · exit 0</> },
},
{
at: 3400,
line: { time: "14:22:08", body: "running · step 3/3 · restart service" },
effect: "incident",
},
{
at: 4300,
line: { time: "14:22:10", body: <><span className="er">monitor</span> · edge-gw-02 tls · connection refused</> },
},
{
at: 5200,
line: { time: "14:22:11", body: <><span className="ok">ok</span> · restart service · exit 0</> },
effect: "runDone",
},
{
at: 6000,
line: { time: "14:22:12", body: <>run finished · <span className="ok">success</span> · 3 steps · 1 server</> },
effect: "revoked",
},
];
const FIRST_LINE: LogLine = { time: "14:22:01", body: "queued · deploy-app · 1 server" };
const MAX_LINES = 7;
export function InstrumentPanel() {
const [lines, setLines] = useState<LogLine[]>([FIRST_LINE]);
const [incident, setIncident] = useState(false);
const [runActive, setRunActive] = useState(true);
const [revoked, setRevoked] = useState(false);
const [resting, setResting] = useState(false);
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const apply = (effect: Beat["effect"]) => {
if (effect === "incident") setIncident(true);
if (effect === "runDone") setRunActive(false);
if (effect === "revoked") setRevoked(true);
};
// Reduced motion gets the finished state immediately rather than no state.
if (reduced) {
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
BEATS.forEach((b) => apply(b.effect));
setResting(true);
return;
}
const timers = BEATS.map((beat) =>
window.setTimeout(() => {
setLines((prev) => [...prev, beat.line].slice(-MAX_LINES));
apply(beat.effect);
}, beat.at)
);
timers.push(window.setTimeout(() => setResting(true), 6600));
return () => timers.forEach(window.clearTimeout);
}, []);
return (
<div className="instrument">
<div className="rail">
<div className="instrument__bar">
<span>
<b>northgate</b> · fleet
</span>
<span>12 servers</span>
<span>{incident ? "10 up" : "11 up"}</span>
<span>{incident ? "2 down" : "1 down"}</span>
<span>3 monitors</span>
<span>{runActive ? "1 run active" : "no runs active"}</span>
<span className="instrument__clock">14:22:12 UTC</span>
</div>
<div className="panes">
<section className="pane" aria-label="Fleet status">
<h2 className="pane__h">
Fleet <span>{revoked ? "key revoked · 1 server updated" : "agents polling"}</span>
</h2>
<Row host="proxmox-node-1" sub="4 keys · 12% cpu" state="up" label="Active" />
<Row host="db-primary" sub="3 keys · 61% cpu" state="up" label="Active" />
<Row
host="edge-gw-02"
sub={incident ? "2 keys · tls refused" : "2 keys · tls 41d"}
state={incident ? "down" : "up"}
label={incident ? "Incident" : "Active"}
/>
<Row host="win-build-01" sub="agent 1.4.1 · update ready" state="pend" label="Pending" />
<Row
host="app-worker-03"
sub={revoked ? "4 keys · 1 revoked" : "5 keys · idle"}
state="up"
label="Active"
/>
</section>
<section className="pane" aria-label="Workflow run">
<h2 className="pane__h">
Run <span>run_8f31c2</span>
</h2>
<div className="stream" aria-live="polite">
{lines.map((line, i) => (
<div key={`${line.time}-${i}`}>
<span className="t">{line.time}</span> {line.body}
</div>
))}
{resting && (
<div>
<span className="caret">_</span>
</div>
)}
</div>
</section>
</div>
</div>
</div>
);
}
function Row({
host,
sub,
state,
label,
}: {
host: string;
sub: string;
state: "up" | "down" | "pend";
label: string;
}) {
return (
<div className="frow">
<span className={state === "up" ? "dot" : `dot dot--${state}`} />
<span className="frow__host">{host}</span>
<span className="frow__sub">{sub}</span>
<span className={`chip chip--${state}`}>{label}</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
// Traced from web/public/images/vantage_logo.svg. The fill is currentColor so
// the mark follows the --logo token in both themes.
export function Logo({ className }: { className?: string }) {
return (
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432 -256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177 0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177 -2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157 -95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39 3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8 -75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165 52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397 -233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185 -321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6 -710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79 46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147 -86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10 708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107 c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590 -6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365 l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393 -194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136 -292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548 -5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188 -112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3 178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73 -40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417 -247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116 343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6 -292 176 -301 174 -2 0 -24 -13 -49 -28z" />
</g>
</svg>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { NAV_LINKS } from "@/components/nav-links";
import { ThemeToggle } from "@/components/ThemeToggle";
export function Nav() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// A route change should never leave the drawer hanging open behind the new page.
useEffect(() => {
setOpen(false);
}, [pathname]);
function current(href: string) {
return pathname === href || pathname === `${href}/` ? "page" : undefined;
}
return (
<>
<header className="nav">
<div className="rail nav__in">
<Link className="brand" href="/">
<Logo />
<b>Vantage</b>
</Link>
<nav className="nav__links" aria-label="Main">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
</nav>
<ThemeToggle />
<button
type="button"
className="icon-btn nav__menu"
aria-expanded={open}
aria-controls="nav-drawer"
onClick={() => setOpen((v) => !v)}
>
{open ? "Close" : "Menu"}
</button>
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
Create organisation
</Link>
</div>
</header>
{open && (
<div className="drawer" id="nav-drawer">
<div className="rail">
<nav aria-label="Main, mobile">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
{link.label}
</Link>
))}
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</nav>
</div>
</div>
)}
</>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { Honeypot } from "@/components/Honeypot";
import { submitSignup, type SubmitResult } from "@/lib/submit";
const MIN_PASSWORD = 12;
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function OrgForm() {
const [slug, setSlug] = useState("");
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setResult({ state: "sending" });
setResult(
await submitSignup({
org_name: String(data.get("org_name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
})
);
}
if (result.state === "sent") {
return (
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its
owner. The link works once and expires in 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact
support@hostxtra.co.uk.
</p>
</div>
);
}
const fieldError = (name: string) => result.fields?.[name];
return (
<form className="form" onSubmit={onSubmit} noValidate>
<Honeypot />
<div className="field">
<label htmlFor="o-org">Organisation name</label>
<input
id="o-org"
name="org_name"
type="text"
placeholder="Northgate Systems"
required
onChange={(e) => setSlug(slugify(e.target.value))}
aria-describedby="o-org-err"
/>
<span className="hostline">
<b>{slug || "your-org"}</b>.vantage.sh
</span>
{fieldError("org_name") && (
<small id="o-org-err" className="field__err">
{fieldError("org_name")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-email">Owner email</label>
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
<small>You become the first owner and can invite the rest of the team afterwards.</small>
{fieldError("email") && (
<small id="o-email-err" className="field__err">
{fieldError("email")}
</small>
)}
</div>
<div className="field">
<label htmlFor="o-pass">Password</label>
<input
id="o-pass"
name="password"
type="password"
autoComplete="new-password"
minLength={MIN_PASSWORD}
required
aria-describedby="o-pass-err"
/>
<small>At least {MIN_PASSWORD} characters. Use a manager you are about to manage SSH keys with it.</small>
{fieldError("password") && (
<small id="o-pass-err" className="field__err">
{fieldError("password")}
</small>
)}
</div>
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
{sending ? "Sending…" : "Create organisation"}
</button>
{result.state === "error" && result.message && (
<p className="field__err" role="alert">
{result.message}
</p>
)}
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
// Applies the stored theme before first paint. Without this the page renders in
// the OS theme for a frame and then snaps to the stored one.
const script = `
(function(){
try {
var t = localStorage.getItem("vantage-theme");
if (t === "light" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();
`;
export function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
const STORAGE_KEY = "vantage-theme";
type Theme = "light" | "dark";
function currentTheme(): Theme {
const set = document.documentElement.getAttribute("data-theme");
if (set === "light" || set === "dark") return set;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme | null>(null);
useEffect(() => {
setTheme(currentTheme());
}, []);
function toggle() {
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
window.localStorage.setItem(STORAGE_KEY, next);
setTheme(next);
}
// Until mounted the rendered label would disagree with the server output, so
// the button carries a neutral label on first paint.
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
return (
<button type="button" className="icon-btn" onClick={toggle} aria-label={`Switch to ${label.toLowerCase()} theme`}>
{label}
</button>
);
}
+6
View File
@@ -0,0 +1,6 @@
export const NAV_LINKS = [
{ href: "/platform", label: "Platform" },
{ href: "/pricing", label: "Pricing" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];
+87
View File
@@ -0,0 +1,87 @@
/*
* The marketing site is a static bundle. Both forms post to sitesvc, which owns
* the contact mailer and the signup flow; the control plane is not involved.
*
* The base URL is baked in at build time. Contact falls back to composing an
* email when sitesvc is not configured, so it never silently swallows what
* someone typed. Signup has no fallback: an account cannot be created over
* mailto, so the form says so rather than pretending.
*/
const SITE_API = (process.env.NEXT_PUBLIC_SITE_API ?? "").replace(/\/$/, "");
const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostxtra.co.uk";
export type SubmitState = "idle" | "sending" | "sent" | "error";
export type SubmitResult = {
state: SubmitState;
/** Message to show when the submission was refused. */
message?: string;
/** Per-field messages, keyed by field name. */
fields?: Record<string, string>;
};
type FieldProblem = { field: string; message: string };
async function post(url: string, payload: unknown): Promise<SubmitResult> {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) return { state: "sent" };
const data = await res.json().catch(() => null);
const problems: FieldProblem[] = data?.fields ?? [];
return {
state: "error",
message: data?.error ?? "That did not go through. Try again in a moment.",
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
};
} catch {
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
}
}
export async function submitContact(fields: {
name: string;
email: string;
servers: string;
topic: string;
message: string;
website: string;
}): Promise<SubmitResult> {
if (!SITE_API) {
const body = [
`Name: ${fields.name}`,
`Email: ${fields.email}`,
`Servers: ${fields.servers}`,
`Topic: ${fields.topic}`,
"",
fields.message,
].join("\n");
window.location.href = `mailto:${FALLBACK_ADDRESS}?subject=${encodeURIComponent(
`Vantage enquiry — ${fields.topic}`
)}&body=${encodeURIComponent(body)}`;
return { state: "sent" };
}
return post(`${SITE_API}/api/contact`, fields);
}
export async function submitSignup(fields: {
org_name: string;
email: string;
password: string;
website: string;
}): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
};
}
return post(`${SITE_API}/api/signup`, fields);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
// Standalone output, matching web/: the build emits a self-contained server
// bundle that runs under Node in the runtime image.
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+6170
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "vantage-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.3",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="533.000000pt" height="610.000000pt" viewBox="246.000000 207.000000 533.000000 610.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#0B2A58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187
-702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559
1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459
272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84
-1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297
-331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0
-1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204
-447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256
-13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333
805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432
-256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177
0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177
-2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157
-95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39
3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8
-75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165
52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397
-233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185
-321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471
483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160
160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73
-158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838
l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6
-710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79
46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147
-86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10
708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107
c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590
-6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365
l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393
-194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136
-292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548
-5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188
-112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3
178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73
-40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417
-247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116
343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6
-292 176 -301 174 -2 0 -24 -13 -49 -28z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+26
View File
@@ -0,0 +1,26 @@
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
FROM alpine:3.20 AS runner
# Needed to verify the SMTP server's TLS certificate.
RUN apk add --no-cache ca-certificates && \
addgroup --system --gid 1001 sitesvc && \
adduser --system --uid 1001 --ingroup sitesvc sitesvc
COPY --from=builder /out/sitesvc /usr/local/bin/sitesvc
USER sitesvc
EXPOSE 8082
ENV PORT=8082
CMD ["/usr/local/bin/sitesvc"]
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mrhid6/vantage/sitesvc/internal/api"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
"github.com/mrhid6/vantage/sitesvc/internal/store"
)
// sitesvc backs the public marketing site. It owns two jobs end to end:
// emailing the contact form, and provisioning an organisation once its owner
// has verified their email address. It shares MongoDB with the control plane —
// that is how the new tenant becomes visible to the app — but shares no code
// and no process with it.
func main() {
// The database name comes from the URI path, e.g.
// mongodb://user:pass@host:27017/vantage?authSource=vantage
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017/vantage")
addr := ":" + getEnv("PORT", "8082")
if err := store.Connect(mongoURI); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
// The unique indexes on users.email and orgs.slug are a security property,
// not an optimisation, so a failure to build them is fatal rather than a
// warning: provisioning tenants without them risks duplicate accounts and
// ambiguous host-based org resolution.
if err := store.EnsureIndexes(); err != nil {
log.Fatalf("failed to ensure indexes: %v", err)
}
mailCfg := mail.FromEnv()
if mailCfg.Enabled() {
log.Printf("smtp enabled (%s) — contact form delivers to %s", mailCfg.Host, mailCfg.To)
} else {
log.Println("warning: SMTP_HOST/SMTP_FROM not set — the contact and signup forms will refuse submissions")
}
if os.Getenv("PUBLIC_URL") == "" {
log.Println("warning: PUBLIC_URL is unset — verification links will be relative and will not work")
}
if os.Getenv("SITE_ORIGIN") == "" {
log.Println("warning: SITE_ORIGIN is unset — cross-origin browser requests will be refused")
}
srv := &http.Server{
Addr: addr,
Handler: api.New(mailCfg).Routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("sitesvc listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
log.Println("sitesvc stopped")
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+20
View File
@@ -0,0 +1,20 @@
module github.com/mrhid6/vantage/sitesvc
go 1.26
require (
github.com/google/uuid v1.6.0
go.mongodb.org/mongo-driver/v2 v2.2.2
golang.org/x/crypto v0.54.0
)
require (
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+50
View File
@@ -0,0 +1,50 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+258
View File
@@ -0,0 +1,258 @@
package api
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
)
const (
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
perIPLimit = 5
perIPWindow = 10 * time.Minute
)
// Server backs the marketing site's two forms: contact, which is emailed and
// never stored, and signup, which provisions an organisation and its owner
// after the address has been verified.
type Server struct {
mail mail.Config
limiter *limiter
signups *limiter
allowOrigin map[string]bool
trustProxy bool
publicURL string
appLoginURL string
}
func New(mailCfg mail.Config) *Server {
return &Server{
mail: mailCfg,
limiter: newLimiter(perIPLimit, perIPWindow),
signups: newLimiter(signupPerIPLimit, signupPerIPWindow),
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
trustProxy: os.Getenv("TRUST_PROXY") == "true",
publicURL: os.Getenv("PUBLIC_URL"),
appLoginURL: os.Getenv("APP_LOGIN_URL"),
}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/contact", s.handleContact)
mux.HandleFunc("POST /api/signup", s.handleSignup)
// Opened from an email client, so it is a GET that renders a page.
mux.HandleFunc("GET /api/verify", s.handleVerify)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
return s.withCORS(mux)
}
// ---------------------------------------------------------------- middleware
func parseOrigins(raw string) map[string]bool {
out := map[string]bool{}
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
out[o] = true
}
}
return out
}
// withCORS reflects only origins named in SITE_ORIGIN. It never answers with a
// wildcard: this endpoint sends mail, and an unset SITE_ORIGIN should fail
// closed for cross-origin callers rather than open to every site on the
// internet.
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && s.allowOrigin[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// clientIP prefers the left-most X-Forwarded-For entry, but only when the
// service is explicitly told it sits behind a proxy. Trusting the header
// unconditionally would let any caller spoof its way past the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
if s.trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ------------------------------------------------------------------- handler
type contactBody struct {
Name string `json:"name"`
Email string `json:"email"`
Servers string `json:"servers"`
Topic string `json:"topic"`
Message string `json:"message"`
Website string `json:"website"` // honeypot: real people leave this empty
}
var (
serverBands = []string{"13", "425", "26100", "More than 100"}
topics = []string{
"Evaluating Vantage",
"Self-hosted licensing",
"Migrating from something else",
"Security disclosure",
}
)
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
var body contactBody
if !decode(w, r, &body) {
return
}
// A filled honeypot is a bot. Answer exactly as we would on success so it
// learns nothing, and send nothing.
if strings.TrimSpace(body.Website) != "" {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
return
}
fields := map[string]string{}
var problems []fieldError
if v, err := text("name", body.Name, true, maxShort); err != nil {
problems = append(problems, *err)
} else {
fields["name"] = v
}
if v, err := text("message", body.Message, true, maxLong); err != nil {
problems = append(problems, *err)
} else {
fields["message"] = v
}
addr, emailErr := email("email", body.Email)
if emailErr != nil {
problems = append(problems, *emailErr)
}
if v, err := oneOf("servers", body.Servers, serverBands); err != nil {
problems = append(problems, *err)
} else {
fields["servers"] = v
}
if v, err := oneOf("topic", body.Topic, topics); err != nil {
problems = append(problems, *err)
} else {
fields["topic"] = v
}
if len(problems) > 0 {
sort.Slice(problems, func(i, j int) bool { return problems[i].Field < problems[j].Field })
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Some fields need another look.",
"fields": problems,
})
return
}
if !s.limiter.allow(s.clientIP(r)) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(perIPWindow.Seconds())))
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "That is a lot of messages in a short time. Try again shortly.",
})
return
}
if !s.mail.Enabled() {
log.Println("contact submission dropped: smtp is not configured")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "The contact form is unavailable right now. Email support@hostxtra.co.uk directly.",
})
return
}
// Nothing is stored, so the send has to succeed before we can tell someone
// their message arrived. This is the one place where a mail failure is the
// caller's problem.
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
log.Printf("contact send: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "We could not send that. Try again, or email support@hostxtra.co.uk directly.",
})
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
}
func subject(addr string, fields map[string]string) string {
return fmt.Sprintf("[Vantage] %s — %s", fields["topic"], addr)
}
func plainBody(addr string, fields map[string]string) string {
var b strings.Builder
b.WriteString("New contact enquiry from the Vantage site.\n\n")
fmt.Fprintf(&b, "Name: %s\n", fields["name"])
fmt.Fprintf(&b, "Email: %s\n", addr)
fmt.Fprintf(&b, "Servers: %s\n", fields["servers"])
fmt.Fprintf(&b, "Topic: %s\n", fields["topic"])
fmt.Fprintf(&b, "Received: %s\n\n", time.Now().UTC().Format(time.RFC1123))
b.WriteString("Message:\n")
b.WriteString(fields["message"])
b.WriteString("\n")
return b.String()
}
// ------------------------------------------------------------------- helpers
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "We could not read that submission.",
})
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("write response: %v", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"sync"
"time"
)
// limiter is a fixed-window counter keyed by client IP. It exists to blunt
// automated submission floods, not to be a precise quota: the window resets
// wholesale, and state is per-process, so it is a speed bump rather than a
// guarantee. The per-email check in the handler backs it up.
type limiter struct {
mu sync.Mutex
hits map[string]*window
limit int
window time.Duration
lastGC time.Time
}
type window struct {
count int
start time.Time
}
func newLimiter(limit int, per time.Duration) *limiter {
return &limiter{
hits: make(map[string]*window),
limit: limit,
window: per,
lastGC: time.Now(),
}
}
func (l *limiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.gc(now)
w, ok := l.hits[key]
if !ok || now.Sub(w.start) > l.window {
l.hits[key] = &window{count: 1, start: now}
return true
}
if w.count >= l.limit {
return false
}
w.count++
return true
}
// gc drops expired windows so a long-running process does not accumulate an
// entry for every IP that ever hit it. Caller must hold the lock.
func (l *limiter) gc(now time.Time) {
if now.Sub(l.lastGC) < l.window {
return
}
for key, w := range l.hits {
if now.Sub(w.start) > l.window {
delete(l.hits, key)
}
}
l.lastGC = now
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"context"
"errors"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/mrhid6/vantage/sitesvc/internal/store"
)
const (
minPasswordLength = 12
signupPerIPLimit = 3
signupPerIPWindow = time.Hour
)
type signupBody struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
Website string `json:"website"` // honeypot
}
// handleSignup records an unverified signup and emails the confirmation link.
// Nothing is created in orgs or users until that link is opened, so an address
// nobody controls can never occupy an email or hold an organisation slug.
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
var body signupBody
if !decode(w, r, &body) {
return
}
// A filled honeypot is a bot. Answer as we would on success so it learns
// nothing, and record nothing.
if strings.TrimSpace(body.Website) != "" {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
return
}
var problems []fieldError
orgName, nameErr := text("org_name", body.OrgName, true, maxShort)
if nameErr != nil {
problems = append(problems, *nameErr)
}
addr, emailErr := email("email", body.Email)
if emailErr != nil {
problems = append(problems, *emailErr)
}
if utf8.RuneCountInString(body.Password) < minPasswordLength {
problems = append(problems, fieldError{
Field: "password",
Message: fmt.Sprintf("Use at least %d characters.", minPasswordLength),
})
}
if len(problems) > 0 {
writeFieldErrors(w, problems)
return
}
if !s.signups.allow(s.clientIP(r)) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(signupPerIPWindow.Seconds())))
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "Too many organisations created from here recently. Try again later.",
})
return
}
if !s.mail.Enabled() {
log.Println("signup refused: smtp is not configured, so no verification email could be sent")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Signup is unavailable right now. Email support@hostxtra.co.uk and we will set you up.",
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
token, err := store.CreatePending(ctx, orgName, addr, body.Password)
switch {
case errors.Is(err, store.ErrEmailTaken):
writeJSON(w, http.StatusConflict, map[string]any{
"error": "That email already has an account.",
"fields": []fieldError{{
Field: "email",
Message: "This address is already registered. Sign in instead.",
}},
})
return
case errors.Is(err, store.ErrNameRejected):
writeFieldErrors(w, []fieldError{{
Field: "org_name",
Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "),
}})
return
case err != nil:
log.Printf("signup: create pending: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "We could not start that signup. Try again in a moment.",
})
return
}
link := s.verifyURL(token)
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
// The pending record is useless without its email, and the address is
// not registered, so the caller must be told rather than left waiting
// for a message that will never arrive.
log.Printf("signup: send verification to %s: %v", addr, err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
})
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
}
func (s *Server) verifyURL(token string) string {
base := strings.TrimSuffix(s.publicURL, "/")
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
}
// handleVerify consumes the token and provisions the organisation. It is opened
// from an email client, so it answers with a page rather than JSON, and
// redirects to the app's sign-in page on success when one is configured.
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
s.verifyPage(w, http.StatusBadRequest, "Link incomplete",
"That link is missing its token. Copy the whole address from the email and try again.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
org, err := store.Verify(ctx, token)
switch {
case errors.Is(err, store.ErrBadToken):
s.verifyPage(w, http.StatusGone, "Link expired",
"This link has already been used or has expired. Start the signup again and we will send a new one.")
return
case errors.Is(err, store.ErrEmailTaken):
s.verifyPage(w, http.StatusConflict, "Already registered",
"That address already has an account. Sign in instead.")
return
case errors.Is(err, store.ErrNameRejected):
s.verifyPage(w, http.StatusUnprocessableEntity, "Name unavailable",
"We could not use that organisation name. Start the signup again with a different one.")
return
case err != nil:
log.Printf("verify: %v", err)
s.verifyPage(w, http.StatusInternalServerError, "Something went wrong",
"We could not finish creating your organisation. Email support@hostxtra.co.uk and we will sort it out.")
return
}
log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID)
if s.appLoginURL != "" {
http.Redirect(w, r, s.appLoginURL, http.StatusSeeOther)
return
}
s.verifyPage(w, http.StatusOK, "Organisation ready",
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
}
// verifyPage renders a minimal self-contained page. Everything interpolated is
// escaped: the only dynamic value is an organisation name the visitor supplied
// themselves, but it still reaches a browser as HTML.
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.WriteHeader(status)
page := fmt.Sprintf(`<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>%s Vantage</title>
<style>
:root { color-scheme: light dark; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: #eaedf3; color: #0a1b33; padding: 2rem;
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 46ch; }
h1 { font-size: 1.6rem; letter-spacing: -0.03em; margin: 0 0 0.6rem; }
p { margin: 0; color: #41556f; }
a { color: #0b2a58; }
@media (prefers-color-scheme: dark) {
body { background: #071628; color: #e4ecf6; }
p { color: #9fb3ca; }
a { color: #5b9be8; }
}
</style>
</head>
<body>
<main>
<h1>%s</h1>
<p>%s</p>
</main>
</body>
</html>`, html.EscapeString(heading), html.EscapeString(heading), html.EscapeString(detail))
if _, err := w.Write([]byte(page)); err != nil {
log.Printf("write verify page: %v", err)
}
}
func writeFieldErrors(w http.ResponseWriter, problems []fieldError) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Some fields need another look.",
"fields": problems,
})
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
// Deliberately loose: the only thing worth rejecting here is something that
// cannot be an address at all. Anything stricter starts refusing valid mail.
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
const (
maxShort = 200
maxLong = 4000
)
type fieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
// text trims, rejects empties when required, and caps length. The cap is on
// runes rather than bytes so a multi-byte message is not silently truncated
// mid-character.
func text(name, value string, required bool, max int) (string, *fieldError) {
v := strings.TrimSpace(value)
if v == "" {
if required {
return "", &fieldError{Field: name, Message: "This field is required."}
}
return "", nil
}
if utf8.RuneCountInString(v) > max {
return "", &fieldError{
Field: name,
Message: fmt.Sprintf("Keep this under %d characters.", max),
}
}
return v, nil
}
func email(name, value string) (string, *fieldError) {
v, err := text(name, value, true, maxShort)
if err != nil {
return "", err
}
v = strings.ToLower(v)
if !emailRe.MatchString(v) {
return "", &fieldError{Field: name, Message: "Enter an email address we can reply to."}
}
return v, nil
}
// oneOf constrains a value to a known set. Submitted values for dropdowns are
// as attacker-controlled as any other field, so they are checked rather than
// trusted and stored.
func oneOf(name, value string, allowed []string) (string, *fieldError) {
v := strings.TrimSpace(value)
for _, a := range allowed {
if v == a {
return v, nil
}
}
return "", &fieldError{Field: name, Message: "Choose one of the listed options."}
}
+174
View File
@@ -0,0 +1,174 @@
package mail
import (
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"strings"
"time"
)
const timeout = 15 * time.Second
// Config is read once at boot. When Host is empty the service still accepts and
// stores submissions; it just does not email them.
type Config struct {
Host string
Port string
Username string
Password string
From string
To string
}
func FromEnv() Config {
return Config{
Host: os.Getenv("SMTP_HOST"),
Port: envOr("SMTP_PORT", "587"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
To: envOr("SMTP_TO", "support@hostxtra.co.uk"),
}
}
func (c Config) Enabled() bool {
return c.Host != "" && c.From != "" && c.To != ""
}
// Port 465 uses implicit TLS; any other port starts plain and upgrades with
// STARTTLS when the server advertises it. Dial and connection deadlines keep an
// unreachable host from hanging the caller until the OS TCP timeout.
// Send delivers a plain-text message. replyTo, when set, becomes the Reply-To
// header so hitting reply in a mail client answers the person who filled in the
// form rather than the service's own sending address. The envelope sender stays
// as From, so a submitted address can never affect SPF or DMARC alignment.
func (c Config) Send(subject, body, replyTo string) error {
return c.sendTo(c.To, subject, body, replyTo)
}
// sendTo delivers to an explicit recipient. Contact enquiries go to the support
// inbox (c.To); verification links go to the person signing up.
func (c Config) sendTo(to, subject, body, replyTo string) error {
if !c.Enabled() {
return fmt.Errorf("smtp: not configured")
}
if to == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(c.Host, c.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
if c.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
}
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if c.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if c.Username != "" {
if err := client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(c.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients(to) {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(c.From, to, subject, body, replyTo)); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return client.Quit()
}
func recipients(to string) []string {
parts := strings.Split(to, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// message builds the MIME body. The subject is encoded rather than interpolated
// raw, and headers are stripped of CR/LF so submitted content cannot inject
// extra headers.
func message(from, to, subject, body, replyTo string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// SendVerification emails the one-time link that completes a signup. The
// address is the person signing up, not the support inbox, so To is overridden
// for this one message.
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
Open this link:
%s
The link works once and expires in %d hours. Until you use it, no account
exists nothing has been created and the address is not registered.
If you did not request this, ignore this email and nothing will happen.
`, orgName, link, int(ttl.Hours()))
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+54
View File
@@ -0,0 +1,54 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
/*
Org and User mirror server/internal/models field for field, because sitesvc
writes into the same collections the control plane reads.
These two structs and the rules in internal/provision are the only places
sitesvc duplicates control-plane logic. If the control plane's shape changes,
these must change with it.
*/
type Org struct {
ID bson.ObjectID `bson:"_id,omitempty"`
OrgID string `bson:"org_id"`
Name string `bson:"name"`
Slug string `bson:"slug"`
CreatedAt time.Time `bson:"created_at"`
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID string `bson:"user_id"`
OrgID string `bson:"org_id"`
Email string `bson:"email"`
PasswordHash string `bson:"password_hash,omitempty"`
Role string `bson:"role"`
AuthSource string `bson:"auth_source"`
CreatedAt time.Time `bson:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty"`
}
// PendingSignup is sitesvc's own record, in its own collection. It holds a
// signup between the form being submitted and the email link being clicked.
//
// Nothing is written to orgs or users until verification succeeds, so an
// unverified address can never occupy an email, hold a slug, or sign in. The
// password is bcrypt-hashed here exactly as it would be in users, so the
// plaintext never rests anywhere.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
OrgName string `bson:"org_name"`
Email string `bson:"email"`
PasswordHash string `bson:"password_hash"`
TokenHash string `bson:"token_hash"`
CreatedAt time.Time `bson:"created_at"`
ExpiresAt time.Time `bson:"expires_at"`
}
+66
View File
@@ -0,0 +1,66 @@
package provision
import (
"fmt"
"regexp"
"strings"
)
/*
Slug rules mirrored from the control plane (server/internal/services: Slugify in
stepscan.go, reservedSlugs and CreateOrg in orgs.go).
They live here rather than being imported because sitesvc is a separate module
with no dependency on the server. That is a deliberate trade: sitesvc stays
small and independent, at the cost of this one duplicated rule set.
Keep the two in step. If the control plane's slug handling, reserved names or
bcrypt cost change, change them here in the same commit nothing enforces the
match automatically, and a divergence would create tenants under rules the app
does not agree with.
*/
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12 // matches services.CreateUser
)
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// ReservedSlugs are names that would collide with a route or a host label.
// Mirrored from services.reservedSlugs.
var ReservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
// BaseSlug derives and validates the slug for an organisation name, returning
// the same errors the control plane's CreateOrg would.
func BaseSlug(name string) (string, error) {
base := Slugify(name)
if len(base) < MinSlugLength {
return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength)
}
if len(base) > MaxSlugLength {
base = base[:MaxSlugLength]
}
if ReservedSlugs[base] {
return "", fmt.Errorf("that organisation name is reserved")
}
return base, nil
}
// NextSlug is the collision suffix scheme: base, base-2, base-3, ...
func NextSlug(base string, attempt int) string {
if attempt < 2 {
return base
}
return fmt.Sprintf("%s-%d", base, attempt)
}
+290
View File
@@ -0,0 +1,290 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"github.com/mrhid6/vantage/sitesvc/internal/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
"golang.org/x/crypto/bcrypt"
)
// PendingTTL is how long a verification link stays valid.
const PendingTTL = 24 * time.Hour
var (
ErrEmailTaken = errors.New("email already registered")
ErrBadToken = errors.New("verification link is invalid or has expired")
ErrNameRejected = errors.New("organisation name rejected")
)
var database *mongo.Database
// Connect dials MongoDB and selects the database named in the connection
// string, e.g. mongodb://host:27017/vantage. The name is parsed with the
// driver's own connection-string parser rather than by hand, so seed lists,
// mongodb+srv, percent-escaping and auth options all behave as the driver
// expects.
//
// A URI with no database is a configuration error worth failing on: defaulting
// would silently provision tenants into the wrong database, where the control
// plane would never see them.
func Connect(uri string) error {
cs, err := connstring.ParseAndValidate(uri)
if err != nil {
return fmt.Errorf("parse MONGO_URI: %w", err)
}
if cs.Database == "" {
return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return err
}
if err := client.Ping(ctx, nil); err != nil {
return err
}
database = client.Database(cs.Database)
return nil
}
// DatabaseName reports the database in use, for startup logging.
func DatabaseName() string {
if database == nil {
return ""
}
return database.Name()
}
func col(name string) *mongo.Collection { return database.Collection(name) }
// EnsureIndexes builds the constraints sitesvc depends on.
//
// The unique indexes on users.email and orgs.slug are the same ones the control
// plane builds at boot, and they are a security property rather than an
// optimisation: without them a duplicate email lets an unscoped user lookup
// match the wrong account, and a duplicate slug makes host-based org resolution
// pick one at random. They are (re)declared here so sitesvc does not depend on
// the server having started first. Creating an existing index is a no-op.
func EnsureIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("users.email index: %w", err)
}
if _, err := col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("orgs.slug index: %w", err)
}
_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "token_hash", Value: 1}},
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "email", Value: 1}}},
// Mongo removes expired pending signups on its own, so an abandoned
// signup does not keep a password hash around indefinitely.
{
Keys: bson.D{{Key: "expires_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
},
})
if err != nil {
return fmt.Errorf("pending signup indexes: %w", err)
}
return nil
}
// EmailTaken reports whether an address already has an account. It is a
// courtesy check for a clear error message; the unique index is what actually
// enforces uniqueness at verification time.
func EmailTaken(ctx context.Context, email string) (bool, error) {
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
return n > 0, err
}
// CreatePending stores an unverified signup and returns the raw token for the
// email link. Only the token's SHA-256 hash is persisted, so a leaked database
// does not yield working verification links.
func CreatePending(ctx context.Context, orgName, email, password string) (string, error) {
if _, err := provision.BaseSlug(orgName); err != nil {
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
taken, err := EmailTaken(ctx, email)
if err != nil {
return "", err
}
if taken {
return "", ErrEmailTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), provision.BcryptCost)
if err != nil {
return "", err
}
raw, err := randomToken()
if err != nil {
return "", err
}
// A second attempt for the same address replaces the first, so the newest
// email is the one that works and old links stop functioning.
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
return "", err
}
now := time.Now().UTC()
pending := models.PendingSignup{
PendingID: uuid.NewString(),
OrgName: strings.TrimSpace(orgName),
Email: email,
PasswordHash: string(hash),
TokenHash: hashToken(raw),
CreatedAt: now,
ExpiresAt: now.Add(PendingTTL),
}
if _, err := col("site_pending_signups").InsertOne(ctx, pending); err != nil {
return "", err
}
return raw, nil
}
// Verify consumes a token and provisions the organisation and its owner.
//
// The pending record is deleted first and atomically, so a token can only ever
// be spent once even if the link is clicked twice at the same moment: the
// second delete matches nothing and stops here.
func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
var pending models.PendingSignup
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
"token_hash": hashToken(rawToken),
"expires_at": bson.M{"$gt": time.Now().UTC()},
}).Decode(&pending)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrBadToken
}
if err != nil {
return nil, err
}
org, err := createOrg(ctx, pending.OrgName)
if err != nil {
return nil, err
}
user := models.User{
UserID: uuid.NewString(),
OrgID: org.OrgID,
Email: pending.Email,
PasswordHash: pending.PasswordHash,
Role: "owner",
AuthSource: "local",
CreatedAt: time.Now().UTC(),
}
if _, err := col("users").InsertOne(ctx, user); err != nil {
// An org with no owner is unreachable and holds a slug nobody can
// reuse, so take it back out. Losing the pending record here is
// acceptable: the address is already registered, which is what the
// duplicate error means.
if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil {
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
}
if mongo.IsDuplicateKeyError(err) {
return nil, ErrEmailTaken
}
return nil, err
}
return org, nil
}
// createOrg mirrors services.CreateOrg: derive the slug, resolve collisions by
// suffixing, and let the unique index settle any race.
func createOrg(ctx context.Context, name string) (*models.Org, error) {
base, err := provision.BaseSlug(name)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
for attempt := 1; attempt <= 50; attempt++ {
slug := provision.NextSlug(base, attempt)
n, err := col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n > 0 {
continue
}
org := models.Org{
OrgID: uuid.NewString(),
Name: name,
Slug: slug,
CreatedAt: time.Now().UTC(),
}
if _, err := col("orgs").InsertOne(ctx, org); err != nil {
// Another signup took this slug between the count and the insert.
// Try the next suffix rather than failing the whole signup.
if mongo.IsDuplicateKeyError(err) {
continue
}
return nil, err
}
return &org, nil
}
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
// rollbackOrg removes an org that never got an owner. It refuses to touch one
// that has users, so a mistaken call can never delete a live tenant.
func rollbackOrg(ctx context.Context, orgID string) error {
n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
if err != nil {
return err
}
if n > 0 {
return fmt.Errorf("refusing to roll back org %s: it has %d user(s)", orgID, n)
}
_, err = col("orgs").DeleteOne(ctx, bson.M{"org_id": orgID})
return err
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+3 -3
View File
@@ -1,5 +1,5 @@
# Dependencies stage
FROM node:20-alpine AS deps
FROM node:26-alpine AS deps
WORKDIR /app
@@ -7,7 +7,7 @@ COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:20-alpine AS builder
FROM node:26-alpine AS builder
WORKDIR /app
@@ -20,7 +20,7 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# Runtime stage
FROM node:20-alpine AS runner
FROM node:26-alpine AS runner
WORKDIR /app
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="533.000000pt" height="610.000000pt" viewBox="246.000000 207.000000 533.000000 610.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#0B2A58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187
-702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559
1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459
272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84
-1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297
-331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0
-1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204
-447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256
-13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333
805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432
-256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177
0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177
-2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157
-95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39
3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8
-75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165
52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397
-233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185
-321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471
483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160
160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73
-158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838
l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6
-710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79
46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147
-86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10
708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107
c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590
-6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365
l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393
-194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136
-292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548
-5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188
-112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3
178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73
-40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417
-247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116
343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6
-292 176 -301 174 -2 0 -24 -13 -49 -28z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB