diff --git a/claude.md b/claude.md index fb7e163..26890ab 100644 --- a/claude.md +++ b/claude.md @@ -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 | diff --git a/deploy/docker-compose.site.yml b/deploy/docker-compose.site.yml index 34f5190..ec7f797 100644 --- a/deploy/docker-compose.site.yml +++ b/deploy/docker-compose.site.yml @@ -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} diff --git a/site/app/platform/page.tsx b/site/app/platform/page.tsx index 298a9ba..763e1d4 100644 --- a/site/app/platform/page.tsx +++ b/site/app/platform/page.tsx @@ -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 ( - <> -
- Platform -

- How the pieces fit together. -

-

- Three moving parts: a control plane you run, an agent on each server, and one outbound connection between - them. -

+ return ( + <> +
+ Platform +

How the pieces fit together.

+

Three moving parts: a control plane you run, an agent on each server, and one outbound connection between them.

-
-
-

The agent never listens.

-
-

- 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. -

-

- 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. -

-
-
+
+
+

The agent never listens.

+
+

+ 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. +

+

+ 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. +

+
+
-
-
- POLL -
-

SyncKeys, every 30s

-

The desired key set for this server. Unchanged state means no disk write at all.

-
-
-
- PUSH -
-

Command stream

-

Generate a key, run a step, apply updates, update the agent, clean up a workspace.

-
-
-
- REPORT -
-

Inventory and checks

-

- Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they - complete. -

-
-
-
-
-
+
+
+ POLL +
+

SyncKeys, every 30s

+

The desired key set for this server. Unchanged state means no disk write at all.

+
+
+
+ PUSH +
+

Command stream

+

Generate a key, run a step, apply updates, update the agent, clean up a workspace.

+
+
+
+ REPORT +
+

Inventory and checks

+

Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they complete.

+
+
+
+ +
-
- Write path -

- The file is never half-written. -

-
-

- The agent computes the desired authorized_keys 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. -

-
-            
-            {"\n"}
-            desired := client.SyncKeys(serverID, token){"\n"}
-            current := keys.ReadAuthorizedKeys(){"\n\n"}
-            if !keys.StateChanged(current, desired) {"{"}
-            {"\n    "}
-            
-            {"\n    "}
-            return nil{"\n"}
-            {"}"}
-            {"\n\n"}
-            keys.WriteAuthorizedKeys(desired){"\n"}
-            
-          
-
-
+
+ Write path +

The file is never half-written.

+
+

+ The agent computes the desired authorized_keys 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. +

+
+                        // agent poll, simplified
+                        {"\n"}
+                        desired := client.SyncKeys(serverID, token){"\n"}
+                        current := keys.ReadAuthorizedKeys(){"\n\n"}
+                        if !keys.StateChanged(current, desired) {"{"}
+                        {"\n    "}
+                        // nothing to do
+                        {"\n    "}
+                        return nil{"\n"}
+                        {"}"}
+                        {"\n\n"}
+                        keys.WriteAuthorizedKeys(desired){"\n"}
+                        // write .tmp, os.Rename(), chmod 0600
+                    
+
+
-
- Tenancy and identity -

- Organisations are the boundary. -

-
-
- Isolation -

Scoped at the query

-

- 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. -

-
-
- Roles -

Owner, admin, member

-

- Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token. -

-
-
- Identity -

Local or OIDC, per organisation

-

- Sign in with email and password, or connect your own provider. Each organisation configures its own issuer - and client. -

-
-
- Sessions -

Server-side, 24 hours

-

- Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs - everyone out and costs no durable data. -

-
-
-
+
+ Tenancy and identity +

Organisations are the boundary.

+
+
+ Isolation +

Scoped at the query

+

+ 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. +

+
+
+ Roles +

Owner, admin, member

+

Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.

+
+
+ Identity +

Local or OIDC, per organisation

+

Sign in with email and password, or connect your own provider. Each organisation configures its own issuer and client.

+
+
+ Sessions +

Server-side, 24 hours

+

Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs everyone out and costs no durable data.

+
+
+
-
- What we do not build -

The scope is the feature.

-
-
- NOT A PROXY -
-

We are never in the SSH path

-

- Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH - still works. -

-
-
-
- NO CUSTODY -
-

Private keys stay put by default

-

- 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. -

-
-
-
- NO PER-USER -
-

Root, not every account

-

- Vantage manages one file per server. Per-user key management is a different product with a different - failure mode. -

-
-
-
- NO PLUGINS -
-

An agent you can read in an evening

-

- A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that - runs as root. -

-
-
-
-
- - ); +
+ What we do not build +

The scope is the feature.

+
+
+ NOT A PROXY +
+

We are never in the SSH path

+

Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH still works.

+
+
+
+ NO CUSTODY +
+

Private keys stay put by default

+

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.

+
+
+
+ NO PER-USER +
+

Root, not every account

+

Vantage manages one file per server. Per-user key management is a different product with a different failure mode.

+
+
+
+ NO PLUGINS +
+

An agent you can read in an evening

+

A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that runs as root.

+
+
+
+
+ + ); } diff --git a/sitesvc/internal/api/signup.go b/sitesvc/internal/api/signup.go index badde5b..670848a 100644 --- a/sitesvc/internal/api/signup.go +++ b/sitesvc/internal/api/signup.go @@ -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 (.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")