This commit is contained in:
mrhid6 committed 2026-07-24 09:24:03 +01:00
1 parent 693d59a3e2
commit 3b52bcbeb8
15 files changed
+527 -588

No files matched your search

+71 -62
View File
@@ -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 |
---
-9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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&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>
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&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>
<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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+1 -1
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -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
)
+2
View File
@@ -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=
+23
View File
@@ -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)
}
-14
View File
@@ -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())