feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s

This commit is contained in:
2026-07-22 16:50:13 +01:00
parent 7a3b8cb700
commit 693d59a3e2
51 changed files with 10656 additions and 12 deletions
+16
View File
@@ -35,3 +35,19 @@ jobs:
-t "$IMAGE" \
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
-t "$IMAGE" \
-f site/Dockerfile site/
docker push "$IMAGE"
- name: Build and push sitesvc image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
docker push "$IMAGE"
+2 -1
View File
@@ -8,4 +8,5 @@ installer/vantage-agent-windows-amd64.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
.next
*.tsbuildinfo
+81 -8
View File
@@ -63,11 +63,24 @@ vantage/
│ ├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
│ └── services/ # business logic + migrations
├── web/
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
│ ├── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
│ └── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms
│ ├── assets/ # image sources, not served
│ └── Dockerfile # same shape as web/: standalone, node, 3000
├── sitesvc/ # public forms: contact mail + signup
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact, signup, verify
│ ├── mail/ # SMTP
│ ├── models/ # mirrors server org/user + pending signup
│ ├── provision/ # slug rules mirrored from the control plane
│ └── store/ # mongo: pending signups, org/user creation
├── proto/vantage/v1/vantage.proto
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, agent.service
@@ -104,6 +117,46 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
### Marketing site and sitesvc
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
`sitesvc/` (port `8082`) owns both flows end to end:
| Form | Endpoint | Effect |
| --- | --- | --- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. |
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to `APP_LOGIN_URL`. |
All three are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# vantage.sh — control plane plus the public site
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
```
### Signup and verification
**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified".
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing.
- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger.
- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works.
- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users.
- Rate limited to 3 signups per client IP per hour, plus a honeypot field.
### The one piece of duplicated logic
`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server.
**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with.
sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op.
---
## Auth and Orgs
@@ -189,6 +242,8 @@ org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
Notes that are not obvious from the structs:
- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending``active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min).
@@ -280,7 +335,21 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external.
**sitesvc** (`deploy/docker-compose.site.yml` only):
| Name | Required | Notes |
| --- | --- | --- |
| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it |
| `APP_LOGIN_URL` | no | where a verified owner is sent to sign in; without it they get a plain confirmation page |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.sh.
---
@@ -331,12 +400,15 @@ GOOS=linux GOARCH=amd64 go build \
-o dist/vantage-agent-linux-amd64 ./cmd
```
### `server-deploy.yml` — triggered on push to `main`
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes the `server` and `web` images to the Gitea container registry, then deploys over SSH:
Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host:
```bash
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
```
### Tagging
@@ -353,10 +425,11 @@ git push origin main # server + web deploy
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `DEPLOY_HOST` | Secret | server VM host |
| `DEPLOY_USER` | Secret | SSH user for deploy |
| `DEPLOY_SSH_KEY` | Secret | deploy private key |
| `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 |
---
+48
View File
@@ -0,0 +1,48 @@
# Public marketing site and its backend. Deliberately kept out of
# docker-compose.yml so a self-hosted install never runs either of them:
#
# self-hosted: docker compose up -d
# vantage.sh: docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
#
# sitesvc owns both public forms end to end. It shares MongoDB with the control
# plane — that is how a new tenant becomes visible to the app — but shares no
# code and no process with it. The control plane has no public signup endpoint.
services:
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
ports:
- 3001:3000
depends_on:
- sitesvc
sitesvc:
image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest
restart: unless-stopped
ports:
- 8082:8082
environment:
PORT: "8082"
# Must point at the same database the control plane uses, or the app
# will not see organisations created here. The database name comes
# from the URI path:
# mongodb://user:pass@host:27017/vantage?authSource=vantage
# A URI with no database is refused at boot rather than defaulted.
MONGO_URI: ${MONGO_URI:-}
# Public base URL of this service. Verification links are built from
# it, so an unset or wrong value produces links that go nowhere.
PUBLIC_URL: ${SITE_PUBLIC_URL:-}
# Where a verified owner is sent to sign in.
APP_LOGIN_URL: ${SITE_APP_LOGIN_URL:-}
# Origins allowed to POST the forms. Unset means every cross-origin
# browser request is refused.
SITE_ORIGIN: ${SITE_ORIGIN:-}
# Only enable behind a proxy that overwrites X-Forwarded-For;
# otherwise clients can spoof their way past the rate limiter.
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
SMTP_HOST: ${SITE_SMTP_HOST:-}
SMTP_PORT: ${SITE_SMTP_PORT:-587}
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
SMTP_FROM: ${SITE_SMTP_FROM:-}
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
out
.env*
npm-debug.log*
+48
View File
@@ -0,0 +1,48 @@
# Dependencies stage
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client
# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty
# and contact falls back to mailto while signup reports it is unavailable.
ARG NEXT_PUBLIC_SITE_API=""
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
RUN npm run build
# Runtime stage
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description:
"Vantage began as a weekend fix for a lost laptop and grew into a fleet control plane. How it is built, and what it deliberately does not do.",
};
export default function AboutPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>
Built for the fleet nobody was given a budget to manage.
</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage started as a weekend fix for a bad afternoon. A laptop was lost, and finding every server that
trusted its key meant SSHing into each one with a text editor open. The list lived in someone&apos;s head.
Two of the boxes were not on it.
</p>
<p>
The obvious tools were all heavier than the problem. A configuration management stack to write one file. A
bastion host that becomes the thing you now have to keep alive. A certificate authority with a rotation
story nobody wanted to own.
</p>
<p>
So it began with one job done properly: hold <code>authorized_keys</code> to a known state. Then the same
agent turned out to be the right place to run a deploy script, check whether a service was answering, and
open a shell when something was on fire. Each addition had to earn its place by riding the connection that
already existed.
</p>
<p>
Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need
to prove who can reach them.
</p>
</div>
<div>
<span className="tag">How it is built</span>
<div className="specs">
<div className="spec">
<span className="spec__k">SERVER</span>
<div>
<h3>Go, MongoDB, Redis</h3>
<p>
One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable;
Redis holds sessions and nothing else.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">WEB</span>
<div>
<h3>Next.js</h3>
<p>
An operations interface, not a brochure: dense tables, live log streams, and state you can read at a
glance.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">AGENT</span>
<div>
<h3>Go, Linux and Windows</h3>
<p>
A single static binary under systemd or as a Windows service. No runtime, no dependencies, no
package manager involved.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CONSOLE</span>
<div>
<h3>Guacamole</h3>
<p>
Protocol handling is a solved problem. We proxy the connection and manage the credentials around it.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Security posture</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The parts worth being specific about.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Tokens</span>
<h3>Hashed, never stored plain</h3>
<p>
Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the
agent&apos;s own disk at 0600 and nowhere else.
</p>
</article>
<article className="cap">
<span className="cap__k">At rest</span>
<h3>AES-256-GCM</h3>
<p>
Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted
with a key held only by your deployment.
</p>
</article>
<article className="cap">
<span className="cap__k">One-time</span>
<h3>Tokens that expire and spend</h3>
<p>
Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the
tunnel opens.
</p>
</article>
<article className="cap">
<span className="cap__k">Recorded</span>
<h3>Every mutation is audited</h3>
<p>
Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and
kept.
</p>
</article>
</div>
</section>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
};
const CHANNELS = [
{
title: "Support",
body: "Everything else, including anything urgent.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk",
},
{
title: "Security disclosure",
body: "Encrypted reports, acknowledged within 72 hours.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
link: "git.vantage.sh/vantage",
href: "https://git.vantage.sh/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
link: "status.vantage.sh",
href: "https://status.vantage.sh",
},
];
export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Tell us what your fleet looks like.
</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
</div>
))}
</div>
</div>
</section>
);
}
+1184
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import "./globals.css";
import { Footer } from "@/components/Footer";
import { Nav } from "@/components/Nav";
import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.sh"),
title: {
default: "Vantage — one control plane for the whole fleet",
template: "%s — Vantage",
},
description:
"Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage — one control plane for the whole fleet",
description:
"Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from "next/link";
import { InstrumentPanel } from "@/components/InstrumentPanel";
export default function OverviewPage() {
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every
machine you manage. One agent per server, outbound connections only, all state in your own database.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
</section>
</div>
<InstrumentPanel />
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
Six tools, six sources of truth, one afternoon lost.
</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks
in a separate service, secrets in a chat thread, and no record of who ran what. None of those systems know
about each other, so every question who can reach this box, what ran on it last, is it even up gets
answered by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>
Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is
not the answer.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>
Assignments, revocations, runs, console sessions and settings changes all land in the same log,
attributed to a person.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and
rewrites <code>authorized_keys</code> atomically.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Execution</span>
<h3>Workflows</h3>
<p>
A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into
workflows that target a set of servers.
</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>
HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target
network.
</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Secrets</span>
<h3>Vault</h3>
<p>
Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never
written to logs.
</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>
Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is
recorded in the audit log.
</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>
CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can
apply from the interface.
</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
</ul>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>
Four steps, and the first three take a minute.
</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an organisation</h3>
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
</div>
</div>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.sh/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.sh/install.ps1 | <b>iex</b>
</pre>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>
Create an organisation, install one agent, and watch a key land on a real box.
</p>
</div>
<Link className="btn btn--solid" href="/start">
Create organisation
</Link>
</div>
</section>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Platform",
description:
"How Vantage fits together: a control plane you run, one agent per server, and a single outbound connection between them.",
};
export default function PlatformPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">Platform</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>
How the pieces fit together.
</h1>
<p className="lede">
Three moving parts: a control plane you run, an agent on each server, and one outbound connection between
them.
</p>
<div className="split" style={{ marginTop: "3rem" }}>
<div>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>The agent never listens.</h2>
<div className="prose" style={{ marginTop: "1rem" }}>
<p>
Every agent dials out to the control plane over gRPC with TLS. Nothing needs an inbound port, nothing
needs a static address, and a machine behind NAT is no different from one with a public IP.
</p>
<p>
Key state is polled on a 30-second interval, because 30 seconds is fine for access control and polling
is simple to reason about. Everything that should not wait running a step, opening a console, applying
updates is pushed down a bidirectional command stream the agent holds open.
</p>
</div>
</div>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">POLL</span>
<div>
<h3>SyncKeys, every 30s</h3>
<p>The desired key set for this server. Unchanged state means no disk write at all.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">PUSH</span>
<div>
<h3>Command stream</h3>
<p>Generate a key, run a step, apply updates, update the agent, clean up a workspace.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REPORT</span>
<div>
<h3>Inventory and checks</h3>
<p>
Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they
complete.
</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Write path</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The file is never half-written.
</h2>
<div className="split split--even" style={{ marginTop: "2rem" }}>
<p className="prose">
The agent computes the desired <code>authorized_keys</code> content, compares it to what is on disk, and
stops there if nothing changed. When it does need to write, it writes a temporary file in the same directory
and renames it over the real one. A machine that loses power mid-write keeps the file it had.
</p>
<pre className="code">
<i>// agent poll, simplified</i>
{"\n"}
desired := client.SyncKeys(serverID, token){"\n"}
current := keys.ReadAuthorizedKeys(){"\n\n"}
<b>if</b> !keys.StateChanged(current, desired) {"{"}
{"\n "}
<i>// nothing to do</i>
{"\n "}
<b>return</b> nil{"\n"}
{"}"}
{"\n\n"}
keys.WriteAuthorizedKeys(desired){"\n"}
<i>// write .tmp, os.Rename(), chmod 0600</i>
</pre>
</div>
</section>
<section className="rail band">
<span className="tag">Tenancy and identity</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>
Organisations are the boundary.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Isolation</span>
<h3>Scoped at the query</h3>
<p>
Every server, key, workflow, monitor and secret belongs to an organisation, and every lookup is filtered
by it. Uniqueness constraints are enforced by the database, not by application logic.
</p>
</article>
<article className="cap">
<span className="cap__k">Roles</span>
<h3>Owner, admin, member</h3>
<p>
Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.
</p>
</article>
<article className="cap">
<span className="cap__k">Identity</span>
<h3>Local or OIDC, per organisation</h3>
<p>
Sign in with email and password, or connect your own provider. Each organisation configures its own issuer
and client.
</p>
</article>
<article className="cap">
<span className="cap__k">Sessions</span>
<h3>Server-side, 24 hours</h3>
<p>
Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs
everyone out and costs no durable data.
</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">What we do not build</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>The scope is the feature.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">NOT A PROXY</span>
<div>
<h3>We are never in the SSH path</h3>
<p>
Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH
still works.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO CUSTODY</span>
<div>
<h3>Private keys stay put by default</h3>
<p>
Keys generated on a server stay on it unless you explicitly upload the private half, and anything stored
is encrypted with a key only your deployment holds.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PER-USER</span>
<div>
<h3>Root, not every account</h3>
<p>
Vantage manages one file per server. Per-user key management is a different product with a different
failure mode.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PLUGINS</span>
<div>
<h3>An agent you can read in an evening</h3>
<p>
A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that
runs as root.
</p>
</div>
</div>
</div>
</section>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
"Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
};
const COMPARISON: [string, string, string, string][] = [
["Managed servers", "3", "Unlimited", "Unlimited"],
["Members", "1", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
["Monitors", "3", "Unlimited", "Unlimited"],
["Secrets vault", "—", "Yes", "Yes"],
["Browser console", "—", "Yes", "Yes"],
["OIDC single sign-on", "—", "Yes", "Yes"],
["Audit history", "30 days", "Forever", "Forever"],
["Runs on your hardware", "—", "—", "Yes"],
["Support", "Community", "Next business day", "Priority"],
];
export default function PricingPage() {
return (
<section className="rail band band--open">
<span className="tag">Pricing</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "17ch" }}>
Per managed server. Nothing else counts.
</h1>
<p className="lede">
People are free. Keys, workflows, monitors and secrets are free. You pay for servers running an agent, because
that is the only number that grows with you.
</p>
<div className="plans">
<div className="plan">
<div>
<div className="plan__n">Solo</div>
<p className="plan__d">A homelab, a couple of VPSes, and the keys on your own laptop.</p>
</div>
<div className="plan__p">
£0 <span>forever</span>
</div>
<ul>
<li>Up to 3 servers</li>
<li>Keys, workflows and monitors</li>
<li>One member, one organisation</li>
<li>Community support</li>
</ul>
<Link className="btn btn--line" href="/start">
Create organisation
</Link>
</div>
<div className="plan plan--pick">
<div>
<div className="plan__n">Fleet</div>
<p className="plan__d">Real infrastructure, and more than one person holding the keys.</p>
</div>
<div className="plan__p">
£4 <span>/ server / month</span>
</div>
<ul>
<li>Unlimited servers and members</li>
<li>Owner, admin and member roles</li>
<li>OIDC single sign-on</li>
<li>Browser console and secrets vault</li>
<li>Full audit history</li>
<li>Email support, next business day</li>
</ul>
<Link className="btn btn--solid" href="/start">
Start 14-day trial
</Link>
</div>
<div className="plan">
<div>
<div className="plan__n">Self-hosted</div>
<p className="plan__d">The whole stack on your metal, behind your own boundary.</p>
</div>
<div className="plan__p">
£290 <span>/ year, per install</span>
</div>
<ul>
<li>Everything in Fleet, no server cap</li>
<li>Your MongoDB, Redis and certificates</li>
<li>Mirror agent releases internally</li>
<li>Priority support and upgrade notes</li>
</ul>
<Link className="btn btn--line" href="/contact">
Talk to us
</Link>
</div>
</div>
<div className="scroll">
<table className="cmp">
<thead>
<tr>
<th>Capability</th>
<th>Solo</th>
<th>Fleet</th>
<th>Self-hosted</th>
</tr>
</thead>
<tbody>
{COMPARISON.map(([capability, solo, fleet, selfHosted]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{solo}</td>
<td>{fleet}</td>
<td>{selfHosted}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: "3.2rem", maxWidth: "66ch" }}>
<span className="tag">Fine print, in plain words</span>
<div className="specs">
<div className="spec">
<span className="spec__k">COUNTING</span>
<div>
<h3>What counts as a server</h3>
<p>One running agent, one server. Remove a box and it stops billing that day.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">LIMITS</span>
<div>
<h3>Going over on Solo</h3>
<p>
Nothing is deleted. A fourth agent registers and heartbeats, but stops syncing keys until you upgrade or
remove a server.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">EXIT</span>
<div>
<h3>Leaving</h3>
<p>
Export every server, key, workflow and secret group as JSON whenever you like. Agents keep their last
synced state on disk, so nobody is locked out mid-migration.
</p>
</div>
</div>
</div>
</div>
</section>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { OrgForm } from "@/components/OrgForm";
export const metadata: Metadata = {
title: "Create organisation",
description:
"An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
};
export default function StartPage() {
return (
<section className="rail band band--open">
<div className="split">
<div>
<span className="tag">Create organisation</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
Set up your organisation.
</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any
other organisation. Confirm your email and it is created with you as its owner.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
<OrgForm />
</div>
</div>
<div>
<span className="tag">What happens next</span>
<div className="specs">
<div className="spec">
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>
We send a link that works once. Your organisation is created when you open it, not before.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key</h3>
<p>
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses
duplicates.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a server</h3>
<p>Run the install command as root. It expires in an hour and works once.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it register</h3>
<p>The server moves from pending to active on first sync, usually inside 30 seconds.</p>
</div>
</div>
</div>
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.sh/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
</div>
</section>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

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

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB