@@ -92,41 +92,50 @@ vantage/
|
||||
## 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.
|
||||
|
||||
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`. |
|
||||
| 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.
|
||||
|
||||
@@ -134,7 +143,7 @@ All three are deliberately **excluded from the self-hosted deployment**: `deploy
|
||||
# self-hosted install — no marketing site, no sitesvc
|
||||
docker compose up -d
|
||||
|
||||
# vantage.sh — control plane plus the public site
|
||||
# vantage.hostxtra.co.uk — control plane plus the public site
|
||||
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
|
||||
```
|
||||
|
||||
@@ -163,7 +172,7 @@ sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
@@ -274,8 +283,8 @@ Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. D
|
||||
```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
|
||||
```
|
||||
@@ -309,11 +318,11 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
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.
|
||||
```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.
|
||||
|
||||
@@ -323,33 +332,33 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
## Environment Variables (server)
|
||||
|
||||
| Name | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
|
||||
| `GUACD_ADDR` | no | default `guacd:4822` |
|
||||
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
|
||||
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
|
||||
| Name | Required | Notes |
|
||||
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
|
||||
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
|
||||
| `MONGO_DB` | no | default `vantage` |
|
||||
| `REDIS_ADDR` | no | default `localhost:6379` |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
|
||||
| `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 |
|
||||
| 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.
|
||||
`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.hostxtra.co.uk.
|
||||
|
||||
---
|
||||
|
||||
@@ -371,20 +380,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -420,16 +429,16 @@ git push origin main # server + web deploy
|
||||
|
||||
### Secrets / variables
|
||||
|
||||
| Name | Type | Value |
|
||||
| --- | --- | --- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | Variable | baked into the `web` image at build time |
|
||||
| `SITE_API_URL` | Variable | sitesvc base URL, baked into the `site` image (contact form) |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; mailto fallback address |
|
||||
| Name | Type | Value |
|
||||
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
|
||||
| `REGISTRY_USER` | Secret | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
|
||||
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
|
||||
| `DOCKER_HOST` | Variable | registry host used for image tags |
|
||||
| `API_URL` | Variable | baked into the `web` image at build time |
|
||||
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
|
||||
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
# 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
|
||||
|
||||
+5
-2
@@ -15,8 +15,11 @@ 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.
|
||||
# bundle. SITE_API is the browser-reachable URL of sitesvc, which serves both
|
||||
# forms. It is effectively required: leave it empty and both forms report they
|
||||
# are not connected rather than submitting anywhere. Must be an origin the
|
||||
# browser can reach (not the internal sitesvc:8082) and be listed in sitesvc's
|
||||
# SITE_ORIGIN for CORS.
|
||||
ARG NEXT_PUBLIC_SITE_API=""
|
||||
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
|
||||
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
|
||||
|
||||
+46
-48
@@ -2,61 +2,59 @@ 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.",
|
||||
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",
|
||||
},
|
||||
{
|
||||
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.hostxtra.co.uk/vantage",
|
||||
href: "https://git.vantage.hostxtra.co.uk/vantage",
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
body: "Control plane uptime and incident history.",
|
||||
link: "status.vantage.hostxtra.co.uk",
|
||||
href: "https://status.vantage.hostxtra.co.uk",
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
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 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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-30
@@ -5,37 +5,35 @@ 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" },
|
||||
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+165
-195
@@ -2,209 +2,179 @@ 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'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>
|
||||
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>
|
||||
<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>
|
||||
<InstrumentPanel />
|
||||
|
||||
<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>
|
||||
<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'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>
|
||||
|
||||
<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>
|
||||
<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">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">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">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">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">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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
</>
|
||||
);
|
||||
<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.hostxtra.co.uk/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
{"\n\n"}
|
||||
<i># Windows</i>
|
||||
{"\n"}
|
||||
<b>irm</b> https://vantage.hostxtra.co.uk/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+58
-64
@@ -2,74 +2,68 @@ 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.",
|
||||
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>
|
||||
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 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>
|
||||
<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>
|
||||
);
|
||||
<pre className="code" style={{ marginTop: "1.8rem" }}>
|
||||
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | \{"\n"}
|
||||
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+85
-103
@@ -7,114 +7,96 @@ 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, "");
|
||||
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";
|
||||
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") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
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];
|
||||
|
||||
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>
|
||||
<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.hostxtra.co.uk
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-58
@@ -14,74 +14,54 @@ const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostx
|
||||
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>;
|
||||
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),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) return { state: "sent" };
|
||||
if (res.ok) return { state: "sent" };
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
const problems: FieldProblem[] = data?.fields ?? [];
|
||||
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." };
|
||||
}
|
||||
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 submitContact(fields: { name: string; email: string; servers: string; topic: string; message: string; website: string }): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
return {
|
||||
state: "error",
|
||||
message: `The contact form is not connected yet. Email ${FALLBACK_ADDRESS} directly.`,
|
||||
};
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
+4
-2
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/api"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/mail"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/store"
|
||||
@@ -21,8 +22,9 @@ import (
|
||||
// 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
|
||||
|
||||
godotenv.Load()
|
||||
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017/vantage")
|
||||
addr := ":" + getEnv("PORT", "8082")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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=
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
@@ -135,6 +137,12 @@ func message(from, to, subject, body, replyTo string) []byte {
|
||||
if replyTo != "" {
|
||||
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
|
||||
}
|
||||
// Date and Message-ID are RFC 5322 essentials. Without them many servers
|
||||
// accept the message at SMTP time and then silently junk or drop it, and
|
||||
// SpamAssassin scores MISSING_DATE and MISSING_MID heavily — the message
|
||||
// "sends" but never lands in the inbox.
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("Message-ID: " + messageID(from) + "\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")
|
||||
@@ -143,6 +151,21 @@ func message(from, to, subject, body, replyTo string) []byte {
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageID builds a unique <id@domain>, taking the domain from the From
|
||||
// address so the identifier matches the sending domain. Falls back to the host
|
||||
// name when From has no domain part.
|
||||
func messageID(from string) string {
|
||||
domain := "vantage.local"
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
|
||||
domain = strings.Trim(from[at+1:], "<> ")
|
||||
}
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
|
||||
}
|
||||
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain)
|
||||
}
|
||||
|
||||
func sanitizeHeader(v string) string {
|
||||
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
|
||||
}
|
||||
|
||||
@@ -74,14 +74,6 @@ func DatabaseName() string {
|
||||
|
||||
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()
|
||||
@@ -119,17 +111,11 @@ func EnsureIndexes() error {
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user