This commit is contained in:
@@ -135,7 +135,7 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
|
||||
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
||||
| 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`. |
|
||||
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to the org's sign-in page (`APP_LOGIN_URL` with `{slug}` filled in). |
|
||||
|
||||
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.
|
||||
|
||||
@@ -350,7 +350,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `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 |
|
||||
| `APP_LOGIN_URL` | no | template for the org sign-in URL a verified owner is redirected to. `{slug}` is replaced with the new org's slug (each org has its own subdomain), e.g. `https://{slug}.vantage.hostxtra.co.uk/login`. A value without `{slug}` is used verbatim; empty means a plain confirmation page. |
|
||||
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
|
||||
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
|
||||
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
|
||||
|
||||
@@ -14,22 +14,10 @@ services:
|
||||
- 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}
|
||||
|
||||
+137
-172
@@ -1,184 +1,149 @@
|
||||
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.",
|
||||
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>
|
||||
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="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>
|
||||
<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>
|
||||
{"\n"}
|
||||
desired := client.SyncKeys(serverID, token){"\n"}
|
||||
current := keys.ReadAuthorizedKeys(){"\n\n"}
|
||||
<b>if</b> !keys.StateChanged(current, desired) {"{"}
|
||||
{"\n "}
|
||||
<i>
|
||||
{"\n "}
|
||||
<b>return</b> nil{"\n"}
|
||||
{"}"}
|
||||
{"\n\n"}
|
||||
keys.WriteAuthorizedKeys(desired){"\n"}
|
||||
<i>
|
||||
</pre>
|
||||
</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">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>
|
||||
</>
|
||||
);
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,8 +159,8 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID)
|
||||
|
||||
if s.appLoginURL != "" {
|
||||
http.Redirect(w, r, s.appLoginURL, http.StatusSeeOther)
|
||||
if login := s.loginURL(org.Slug); login != "" {
|
||||
http.Redirect(w, r, login, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,6 +168,21 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
|
||||
}
|
||||
|
||||
// loginURL is the org-specific sign-in URL a verified owner is sent to. Each
|
||||
// org lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// must be substituted per signup rather than pointing at one shared address.
|
||||
//
|
||||
// APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the
|
||||
// org's slug; a value without one is treated as a literal (a single shared
|
||||
// login page) so a plain URL still works. An empty value falls back to the
|
||||
// confirmation page.
|
||||
func (s *Server) loginURL(slug string) string {
|
||||
if s.appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(s.appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user