style(rfc): apply repo prettier formatting to the two RFCs
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Whitespace/table-padding/emphasis-marker-style only (prettier --write), to satisfy pnpm format:check (globs **/*.md). No wording, numbers, [VERIFY] flags, or disclaimers changed — verified against the source files line-for-line modulo formatting.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
- **Sponsor / veto:** Jason (human lead)
|
||||
- **Date:** 2026-07-24
|
||||
- **Program:** Mosaic Stack comms-evolution
|
||||
- **Companion to:** RFC-001 — *MACP: A Mosaic-Native, Matrix-Native Comms Layer*. RFC-001 is the architecture (self-hosted Synapse + Mosaic appservice backbone + `packages/comms` SDK + MACP standard + per-site federation). **RFC-002 is the config substrate the whole thing installs and runs on.**
|
||||
- **Companion to:** RFC-001 — _MACP: A Mosaic-Native, Matrix-Native Comms Layer_. RFC-001 is the architecture (self-hosted Synapse + Mosaic appservice backbone + `packages/comms` SDK + MACP standard + per-site federation). **RFC-002 is the config substrate the whole thing installs and runs on.**
|
||||
- **Audience:** Team Leads, the Mosaic orchestrator, infra, harness maintainers, and — critically — **strangers who install this open-source product on hardware we will never see.**
|
||||
|
||||
> This is a **design document**. No code ships from this RFC. It is written to be decomposed into missions with per-phase acceptance criteria, and it slots under RFC-001's P1→P5. Every uncertain or must-live-validate claim is flagged **[VERIFY]**.
|
||||
@@ -37,7 +37,7 @@ RFC-002 is the substrate; **RFC-001's P1 (presence) needs only Mode A/B single-i
|
||||
- **G1 — Installable by a stranger.** A person with no relationship to our fleet can install, configure, and run the comms system from published artifacts and a guided installer, on their own hardware and domains, with no edits to product code.
|
||||
- **G2 — Zero hardcoded topology.** Every topology fact — `server_name`, homeserver host/IP, delegation method, federation peers, cert mode, secret backend — is **user-supplied config**, validated at install, stored in the product DB. No fleet-specific literal ships in the product.
|
||||
- **G3 — Standalone MUST work.** The PRIMARY instance is always fully functional with **zero federation**, including with **no DNS at all** (Mode C, IP-only). Presence, rooms, MACP, HIL-via-Element all work single-instance.
|
||||
- **G4 — Federation is optional but honestly gated.** Federation is opt-in and, when opted into, **requires DNS + valid certificates as a hard precondition.** The installer must refuse to *claim* federation is working when the DNS/cert preconditions aren't met.
|
||||
- **G4 — Federation is optional but honestly gated.** Federation is opt-in and, when opted into, **requires DNS + valid certificates as a hard precondition.** The installer must refuse to _claim_ federation is working when the DNS/cert preconditions aren't met.
|
||||
- **G5 — One ACME integration, two CA choices.** Build a single ACME cert-provisioning path; the operator selects step-ca or Let's Encrypt by supplying an **ACME directory URL** plus a challenge type. No second, bespoke cert path.
|
||||
- **G6 — No forced paid dependency for secrets.** A pluggable `SecretBackend` with at least Vault and Vaultwarden implementations, chosen at install. Open-source ethos: the free/self-hostable path must be first-class.
|
||||
- **G7 — Defaults that just work, overrides where they matter.** DB-backed config with sane defaults so most operators change little; install-time overrides for the topology-critical values; a clear immutable-vs-tunable boundary so operators can't foot-gun `server_name`.
|
||||
@@ -45,8 +45,8 @@ RFC-002 is the substrate; **RFC-001's P1 (presence) needs only Mode A/B single-i
|
||||
|
||||
### 1.2 Non-Goals
|
||||
|
||||
- **NG1 — Not hosting a managed service.** This RFC is about *self-install*. We are not building multi-tenant SaaS provisioning; each operator runs their own instance(s).
|
||||
- **NG2 — Not a new cert stack.** We do not write our own CA, our own ACME client protocol, or a non-ACME cert path. We integrate ACME and let the operator point it at step-ca or Let's Encrypt. (We *may* bundle/recommend step-ca as the self-hosted CA, but via its standard ACME provisioner, not a fork.)
|
||||
- **NG1 — Not hosting a managed service.** This RFC is about _self-install_. We are not building multi-tenant SaaS provisioning; each operator runs their own instance(s).
|
||||
- **NG2 — Not a new cert stack.** We do not write our own CA, our own ACME client protocol, or a non-ACME cert path. We integrate ACME and let the operator point it at step-ca or Let's Encrypt. (We _may_ bundle/recommend step-ca as the self-hosted CA, but via its standard ACME provisioner, not a fork.)
|
||||
- **NG3 — Not a new secret manager.** We define an interface and ship adapters. We do not build a secret store; we do not force one.
|
||||
- **NG4 — Not public-network Matrix federation.** Consistent with RFC-001 NG5: federation is Mosaic-site-to-Mosaic-site over infrastructure the operator controls, allowlisted. No `matrix.org` federation.
|
||||
- **NG5 — Not making IP-only federate.** We will not ship a hack (self-signed S2S trust bundles, `/etc/hosts` federation) that pretends IP-only can federate. IP-only is standalone. This is a deliberate, honest boundary (§2.4, §7).
|
||||
@@ -63,7 +63,7 @@ Matrix has exactly the split Jason described, natively:
|
||||
- **`server_name`** — the Synapse config value that is the server's **identity domain**. It is the part after the colon in every MXID (`@mosaic_agent:mosaic.woltje.com`) and every room alias (`#mission-KBN-101:mosaic.woltje.com`). It is **baked into every identity the moment that identity is minted.** Changing it re-homes everything. This is `server_name` in Synapse's `homeserver.yaml`.
|
||||
- **Homeserver host** — the actual network location (hostname:port or IP:port) where the Synapse process answers federation and (optionally proxied) client traffic. It **can differ** from `server_name`. Matrix reconciles the difference through **delegation**: `https://<server_name>/.well-known/matrix/server` returning `{"m.server": "matrix.woltje.com:443"}`, and/or a `_matrix._tcp.<server_name>` **SRV** record. **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse version (RFC-001 §6 flags the same).
|
||||
|
||||
So Jason's "mosaic.* app-domain + matrix.* homeserver-domain" split maps precisely: **`server_name = mosaic.woltje.com` (identity, in MXIDs), homeserver runs at `matrix.woltje.com` (discovered via delegation).** That is **Mode A**.
|
||||
So Jason's "mosaic._ app-domain + matrix._ homeserver-domain" split maps precisely: **`server_name = mosaic.woltje.com` (identity, in MXIDs), homeserver runs at `matrix.woltje.com` (discovered via delegation).** That is **Mode A**.
|
||||
|
||||
### 2.2 The topology config schema
|
||||
|
||||
@@ -72,57 +72,59 @@ One canonical config object, stored in the product DB (§5), populated at instal
|
||||
```jsonc
|
||||
{
|
||||
"topology": {
|
||||
"mode": "split-domain | single-domain | ip-only-standalone", // A | B | C — install-time-immutable
|
||||
"mode": "split-domain | single-domain | ip-only-standalone", // A | B | C — install-time-immutable
|
||||
|
||||
"identity": {
|
||||
"server_name": "mosaic.woltje.com", // INSTALL-TIME-IMMUTABLE. In MXIDs. Never change without re-home (§5.3).
|
||||
"server_name_kind": "domain | ip" // "ip" only legal in Mode C
|
||||
"server_name": "mosaic.woltje.com", // INSTALL-TIME-IMMUTABLE. In MXIDs. Never change without re-home (§5.3).
|
||||
"server_name_kind": "domain | ip", // "ip" only legal in Mode C
|
||||
},
|
||||
|
||||
"homeserver": {
|
||||
"host": "matrix.woltje.com", // where Synapse actually listens (Mode A: differs from server_name)
|
||||
"port": 8448, // federation port (default 8448) or 443 if proxied — [VERIFY] per deploy
|
||||
"client_bind": "https://matrix.woltje.com", // C-S API public URL (proxied)
|
||||
"bind_ip": null // Mode C: e.g. "192.168.1.50" ; Modes A/B: null (DNS-resolved)
|
||||
"host": "matrix.woltje.com", // where Synapse actually listens (Mode A: differs from server_name)
|
||||
"port": 8448, // federation port (default 8448) or 443 if proxied — [VERIFY] per deploy
|
||||
"client_bind": "https://matrix.woltje.com", // C-S API public URL (proxied)
|
||||
"bind_ip": null, // Mode C: e.g. "192.168.1.50" ; Modes A/B: null (DNS-resolved)
|
||||
},
|
||||
|
||||
"delegation": {
|
||||
"method": "well-known | srv | none", // Mode A: well-known or srv ; Mode B/C: none
|
||||
"well_known_server": { "m.server": "matrix.woltje.com:443" }, // if method=well-known
|
||||
"srv_record": "_matrix._tcp.mosaic.woltje.com. 3600 IN SRV 10 0 443 matrix.woltje.com." // if method=srv (documented, operator provisions)
|
||||
"method": "well-known | srv | none", // Mode A: well-known or srv ; Mode B/C: none
|
||||
"well_known_server": { "m.server": "matrix.woltje.com:443" }, // if method=well-known
|
||||
"srv_record": "_matrix._tcp.mosaic.woltje.com. 3600 IN SRV 10 0 443 matrix.woltje.com.", // if method=srv (documented, operator provisions)
|
||||
},
|
||||
|
||||
"federation": {
|
||||
"enabled": true, // OPTIONAL. Mode C forces false.
|
||||
"domain_whitelist": [ // Synapse federation_domain_whitelist — allowlist ONLY
|
||||
"enabled": true, // OPTIONAL. Mode C forces false.
|
||||
"domain_whitelist": [
|
||||
// Synapse federation_domain_whitelist — allowlist ONLY
|
||||
"mosaic.woltje.com",
|
||||
"mosaic.uscllc.com"
|
||||
"mosaic.uscllc.com",
|
||||
],
|
||||
"peers": [
|
||||
// operator-declared peer sites (for room/fleet wiring)
|
||||
{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true },
|
||||
],
|
||||
"peers": [ // operator-declared peer sites (for room/fleet wiring)
|
||||
{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }
|
||||
]
|
||||
},
|
||||
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme.mosaic.woltje.com/acme/acme/directory", // step-ca OR https://acme-v02.api.letsencrypt.org/directory
|
||||
"ca_kind": "step-ca | letsencrypt", // informational label; the directory_url is the real switch
|
||||
"directory_url": "https://acme.mosaic.woltje.com/acme/acme/directory", // step-ca OR https://acme-v02.api.letsencrypt.org/directory
|
||||
"ca_kind": "step-ca | letsencrypt", // informational label; the directory_url is the real switch
|
||||
"challenge": "dns-01 | http-01 | tls-alpn-01",
|
||||
"account_email": "ops@woltje.com", // ACME account contact
|
||||
"eab": { "kid": null, "hmac_key_ref": null } // External Account Binding if the CA requires it (some step-ca provisioners) — secret via SecretBackend
|
||||
"account_email": "ops@woltje.com", // ACME account contact
|
||||
"eab": { "kid": null, "hmac_key_ref": null }, // External Account Binding if the CA requires it (some step-ca provisioners) — secret via SecretBackend
|
||||
},
|
||||
"client_tls_mode": "acme | self-signed" // Mode C may use self-signed for local C-S TLS (weaker trust, §8)
|
||||
"client_tls_mode": "acme | self-signed", // Mode C may use self-signed for local C-S TLS (weaker trust, §8)
|
||||
},
|
||||
|
||||
"secrets": {
|
||||
"backend": "vault | vaultwarden", // pluggable, install-time choice (§4)
|
||||
"backend": "vault | vaultwarden", // pluggable, install-time choice (§4)
|
||||
"connection": {
|
||||
"address": "https://vault.woltje.com:8200", // or Vaultwarden/Bitwarden server URL
|
||||
"auth_ref": "…", // how the appservice authenticates to the backend (bootstrap, §4/§8)
|
||||
"namespace_or_org": "mosaic-fleet" // Vault namespace / mount, OR Vaultwarden org id
|
||||
}
|
||||
}
|
||||
}
|
||||
"address": "https://vault.woltje.com:8200", // or Vaultwarden/Bitwarden server URL
|
||||
"auth_ref": "…", // how the appservice authenticates to the backend (bootstrap, §4/§8)
|
||||
"namespace_or_org": "mosaic-fleet", // Vault namespace / mount, OR Vaultwarden org id
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -130,48 +132,79 @@ One canonical config object, stored in the product DB (§5), populated at instal
|
||||
|
||||
Exactly three supported modes (Jason's ruling — no others):
|
||||
|
||||
**Mode A — split-domain (identity ≠ host, delegated).** *Jason's PRIMARY.* Federation-capable. This is the recommended production shape because it lets identity live on a clean app-domain while the homeserver runs on a separate operational host.
|
||||
**Mode A — split-domain (identity ≠ host, delegated).** _Jason's PRIMARY._ Federation-capable. This is the recommended production shape because it lets identity live on a clean app-domain while the homeserver runs on a separate operational host.
|
||||
|
||||
```jsonc
|
||||
// Mode A — mosaic.woltje.com identity, matrix.woltje.com host, federated with a second site
|
||||
{ "topology": {
|
||||
"mode": "split-domain",
|
||||
"identity": { "server_name": "mosaic.woltje.com", "server_name_kind": "domain" },
|
||||
"homeserver": { "host": "matrix.woltje.com", "port": 443, "client_bind": "https://matrix.woltje.com", "bind_ip": null },
|
||||
"delegation": { "method": "well-known", "well_known_server": { "m.server": "matrix.woltje.com:443" } },
|
||||
"federation": {
|
||||
"enabled": true,
|
||||
"domain_whitelist": ["mosaic.woltje.com", "mosaic.uscllc.com"],
|
||||
"peers": [ { "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true } ]
|
||||
{
|
||||
"topology": {
|
||||
"mode": "split-domain",
|
||||
"identity": { "server_name": "mosaic.woltje.com", "server_name_kind": "domain" },
|
||||
"homeserver": {
|
||||
"host": "matrix.woltje.com",
|
||||
"port": 443,
|
||||
"client_bind": "https://matrix.woltje.com",
|
||||
"bind_ip": null,
|
||||
},
|
||||
"delegation": {
|
||||
"method": "well-known",
|
||||
"well_known_server": { "m.server": "matrix.woltje.com:443" },
|
||||
},
|
||||
"federation": {
|
||||
"enabled": true,
|
||||
"domain_whitelist": ["mosaic.woltje.com", "mosaic.uscllc.com"],
|
||||
"peers": [{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }],
|
||||
},
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory", // public LE, or a step-ca directory
|
||||
"ca_kind": "letsencrypt",
|
||||
"challenge": "dns-01",
|
||||
"account_email": "ops@woltje.com",
|
||||
},
|
||||
"client_tls_mode": "acme",
|
||||
},
|
||||
"secrets": {
|
||||
"backend": "vaultwarden",
|
||||
"connection": { "address": "https://vw.woltje.com", "namespace_or_org": "mosaic-fleet" },
|
||||
},
|
||||
},
|
||||
"tls": { "acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory", // public LE, or a step-ca directory
|
||||
"ca_kind": "letsencrypt", "challenge": "dns-01", "account_email": "ops@woltje.com" },
|
||||
"client_tls_mode": "acme" },
|
||||
"secrets": { "backend": "vaultwarden",
|
||||
"connection": { "address": "https://vw.woltje.com", "namespace_or_org": "mosaic-fleet" } }
|
||||
} }
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs on this instance: `@mosaic_coordinator-1:mosaic.woltje.com`. A human/agent's homeserver is discovered by resolving `.well-known/matrix/server` on `mosaic.woltje.com` → `matrix.woltje.com:443`.
|
||||
|
||||
**Mode B — single-domain (identity == host).** Simpler; the `server_name` *is* the host. No delegation needed. Federation-capable (still needs DNS + cert on that one domain).
|
||||
**Mode B — single-domain (identity == host).** Simpler; the `server_name` _is_ the host. No delegation needed. Federation-capable (still needs DNS + cert on that one domain).
|
||||
|
||||
```jsonc
|
||||
// Mode B — one domain does everything
|
||||
{ "topology": {
|
||||
"mode": "single-domain",
|
||||
"identity": { "server_name": "matrix.example.org", "server_name_kind": "domain" },
|
||||
"homeserver": { "host": "matrix.example.org", "port": 8448, "client_bind": "https://matrix.example.org", "bind_ip": null },
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // optional — off here
|
||||
"tls": { "acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"ca_kind": "letsencrypt", "challenge": "http-01", "account_email": "admin@example.org" },
|
||||
"client_tls_mode": "acme" },
|
||||
"secrets": { "backend": "vault",
|
||||
"connection": { "address": "https://vault.example.org:8200", "namespace_or_org": "mosaic" } }
|
||||
} }
|
||||
{
|
||||
"topology": {
|
||||
"mode": "single-domain",
|
||||
"identity": { "server_name": "matrix.example.org", "server_name_kind": "domain" },
|
||||
"homeserver": {
|
||||
"host": "matrix.example.org",
|
||||
"port": 8448,
|
||||
"client_bind": "https://matrix.example.org",
|
||||
"bind_ip": null,
|
||||
},
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // optional — off here
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"ca_kind": "letsencrypt",
|
||||
"challenge": "http-01",
|
||||
"account_email": "admin@example.org",
|
||||
},
|
||||
"client_tls_mode": "acme",
|
||||
},
|
||||
"secrets": {
|
||||
"backend": "vault",
|
||||
"connection": { "address": "https://vault.example.org:8200", "namespace_or_org": "mosaic" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs: `@mosaic_coordinator-1:matrix.example.org`. Here `server_name == host`, so `@:matrix.example.org` is both the identity domain and where Synapse actually answers.
|
||||
@@ -180,16 +213,25 @@ MXIDs: `@mosaic_coordinator-1:matrix.example.org`. Here `server_name == host`, s
|
||||
|
||||
```jsonc
|
||||
// Mode C — airgapped / local, no DNS, standalone only
|
||||
{ "topology": {
|
||||
"mode": "ip-only-standalone",
|
||||
"identity": { "server_name": "192.168.1.50:8448", "server_name_kind": "ip" },
|
||||
"homeserver": { "host": "192.168.1.50", "port": 8448, "client_bind": "https://192.168.1.50:8448", "bind_ip": "192.168.1.50" },
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // FORCED false in Mode C
|
||||
"tls": { "acme": null, "client_tls_mode": "self-signed" }, // may use a private step-ca or self-signed for C-S TLS
|
||||
"secrets": { "backend": "vaultwarden",
|
||||
"connection": { "address": "http://192.168.1.51:8080", "namespace_or_org": "mosaic-local" } }
|
||||
} }
|
||||
{
|
||||
"topology": {
|
||||
"mode": "ip-only-standalone",
|
||||
"identity": { "server_name": "192.168.1.50:8448", "server_name_kind": "ip" },
|
||||
"homeserver": {
|
||||
"host": "192.168.1.50",
|
||||
"port": 8448,
|
||||
"client_bind": "https://192.168.1.50:8448",
|
||||
"bind_ip": "192.168.1.50",
|
||||
},
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // FORCED false in Mode C
|
||||
"tls": { "acme": null, "client_tls_mode": "self-signed" }, // may use a private step-ca or self-signed for C-S TLS
|
||||
"secrets": {
|
||||
"backend": "vaultwarden",
|
||||
"connection": { "address": "http://192.168.1.51:8080", "namespace_or_org": "mosaic-local" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs: `@mosaic_coordinator-1:192.168.1.50:8448`. **Warning surfaced at install:** this `server_name` is an IP literal; if the operator ever wants federation they must move to a domain, which is an **identity re-home** (§5.3, §7).
|
||||
@@ -200,11 +242,11 @@ MXIDs: `@mosaic_coordinator-1:192.168.1.50:8448`. **Warning surfaced at install:
|
||||
|
||||
**Federation REQUIRES DNS + valid certificates. This is a hard stop, enforced by the installer and by the config validator, not a suggestion.**
|
||||
|
||||
| Precondition | Why | Enforced where |
|
||||
|---|---|---|
|
||||
| `server_name` resolves in public/peer DNS (or delegated target does) | S2S discovery uses `.well-known`/SRV over DNS; peers must resolve you | installer reachability check (§6); config validator rejects `federation.enabled=true` with `server_name_kind=ip` |
|
||||
| Valid TLS cert on the federation endpoint, chained to a CA the peer trusts | S2S is TLS; a peer validates your cert. Self-signed/untrusted ⇒ peer refuses ⇒ you are defederated | installer cert probe (§6); cert monitor (§3.4) |
|
||||
| Federation `domain_whitelist` non-empty and mutually consistent with peers | allowlist-only federation (RFC-001 NG5/§6) | config validator |
|
||||
| Precondition | Why | Enforced where |
|
||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `server_name` resolves in public/peer DNS (or delegated target does) | S2S discovery uses `.well-known`/SRV over DNS; peers must resolve you | installer reachability check (§6); config validator rejects `federation.enabled=true` with `server_name_kind=ip` |
|
||||
| Valid TLS cert on the federation endpoint, chained to a CA the peer trusts | S2S is TLS; a peer validates your cert. Self-signed/untrusted ⇒ peer refuses ⇒ you are defederated | installer cert probe (§6); cert monitor (§3.4) |
|
||||
| Federation `domain_whitelist` non-empty and mutually consistent with peers | allowlist-only federation (RFC-001 NG5/§6) | config validator |
|
||||
|
||||
**IP-only ⇒ federation is impossible.** There is no valid public/peer CA cert for a bare IP in our trust model (and we will not ship a self-signed S2S trust hack — NG5). Therefore **Mode C is standalone-only by construction**, and the config validator makes `mode=ip-only-standalone ∧ federation.enabled=true` an **illegal state that cannot be persisted.**
|
||||
|
||||
@@ -228,10 +270,10 @@ This is the honest, load-bearing boundary of the whole topology model:
|
||||
|
||||
We build **exactly one** cert-provisioning integration: an **ACME client integration**. Both supported CAs are ACME CAs. The operator does **not** choose between two code paths; they choose an **ACME directory URL** and a **challenge type**. That is the entire surface.
|
||||
|
||||
| CA choice | What it is | ACME directory URL (illustrative) | Why an operator picks it |
|
||||
|---|---|---|---|
|
||||
| **step-ca (Smallstep)** | self-hosted **private** ACME CA | `https://acme.<internal-domain>/acme/<provisioner>/directory` | **Total control**; issues for **private/internal/split-horizon domains** a public CA can't (e.g. `mosaic.internal`, RFC-1918 split-horizon); airgap-friendly; you own the root |
|
||||
| **Let's Encrypt** | public ACME CA | `https://acme-v02.api.letsencrypt.org/directory` (staging: `.../acme-staging-v02...`) | **Ease of use**; universally trusted chain (ISRG Root X1); zero CA to operate; ideal for public domains |
|
||||
| CA choice | What it is | ACME directory URL (illustrative) | Why an operator picks it |
|
||||
| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **step-ca (Smallstep)** | self-hosted **private** ACME CA | `https://acme.<internal-domain>/acme/<provisioner>/directory` | **Total control**; issues for **private/internal/split-horizon domains** a public CA can't (e.g. `mosaic.internal`, RFC-1918 split-horizon); airgap-friendly; you own the root |
|
||||
| **Let's Encrypt** | public ACME CA | `https://acme-v02.api.letsencrypt.org/directory` (staging: `.../acme-staging-v02...`) | **Ease of use**; universally trusted chain (ISRG Root X1); zero CA to operate; ideal for public domains |
|
||||
|
||||
Because both speak ACME, the same client (account key, order, authorization, challenge, finalize, cert-fetch, renew) drives either. The `ca_kind` label in config is informational for UX; the **`directory_url` is the real determinant**. **[VERIFY]** whether the chosen ACME library requires per-CA quirks (LE rate limits, staging switch; step-ca **External Account Binding** on some provisioners — if EAB is required the `kid`/`hmac_key` come from the SecretBackend, §4).
|
||||
|
||||
@@ -239,13 +281,14 @@ Because both speak ACME, the same client (account key, order, authorization, cha
|
||||
|
||||
The operator picks one challenge type per the domains they're covering. This is the crux for **public vs private/split-horizon**:
|
||||
|
||||
| Challenge | How it proves control | Best for | Cannot / caveat |
|
||||
|---|---|---|---|
|
||||
| **HTTP-01** | CA GETs `http://<domain>/.well-known/acme-challenge/<token>` on port 80 | **Public, single hostname**, port 80 reachable from CA (Mode B, or Mode A's homeserver host) | Needs inbound :80 from the CA; **cannot** do wildcards; **useless for private domains** a public CA can't reach |
|
||||
| **DNS-01** | CA checks a `_acme-challenge.<domain>` **TXT** record you publish | **Private / split-horizon / internal domains**, **wildcards**, and any domain where inbound HTTP from the CA is impossible | Requires **programmatic DNS API** access to publish TXT (or manual for step-ca where you own the resolver). **This is the answer for step-ca on private domains** and for Mode A when the homeserver host isn't publicly HTTP-reachable |
|
||||
| **TLS-ALPN-01** | CA connects TLS on :443 with ALPN `acme-tls/1` | Public host where **:443 is free** but :80 is blocked | Needs the ACME client to own the :443 TLS handshake briefly; awkward behind some reverse proxies — **[VERIFY]** against our proxy (RFC-001 terminates TLS at a reverse proxy) |
|
||||
| Challenge | How it proves control | Best for | Cannot / caveat |
|
||||
| --------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **HTTP-01** | CA GETs `http://<domain>/.well-known/acme-challenge/<token>` on port 80 | **Public, single hostname**, port 80 reachable from CA (Mode B, or Mode A's homeserver host) | Needs inbound :80 from the CA; **cannot** do wildcards; **useless for private domains** a public CA can't reach |
|
||||
| **DNS-01** | CA checks a `_acme-challenge.<domain>` **TXT** record you publish | **Private / split-horizon / internal domains**, **wildcards**, and any domain where inbound HTTP from the CA is impossible | Requires **programmatic DNS API** access to publish TXT (or manual for step-ca where you own the resolver). **This is the answer for step-ca on private domains** and for Mode A when the homeserver host isn't publicly HTTP-reachable |
|
||||
| **TLS-ALPN-01** | CA connects TLS on :443 with ALPN `acme-tls/1` | Public host where **:443 is free** but :80 is blocked | Needs the ACME client to own the :443 TLS handshake briefly; awkward behind some reverse proxies — **[VERIFY]** against our proxy (RFC-001 terminates TLS at a reverse proxy) |
|
||||
|
||||
**Guidance baked into the installer:**
|
||||
|
||||
- **Let's Encrypt + public domain, port 80 open →** HTTP-01 (simplest). Wildcard or no inbound :80 → DNS-01.
|
||||
- **step-ca + private/internal/split-horizon domain →** **DNS-01** (the private CA can validate against a resolver you control; public HTTP reachability is irrelevant). This is the combination that lets a private homelab domain get real certs.
|
||||
- **:443-only public host →** TLS-ALPN-01.
|
||||
@@ -265,9 +308,10 @@ For Mode A, the cert and the delegation must agree. Concretely, at install for `
|
||||
**This is the operational trap and it must alarm.** ACME certs are short-lived (LE = 90 days; step-ca often shorter by policy). A federation cert that lapses does **not** throw a loud error — peers simply **stop trusting the S2S handshake and the site silently drops out of federation.** From inside, everything looks fine; from peers, the site went dark. That is exactly the "homelab went dark and took comms with it" trauma (RFC-001 §5), but caused by a cert, not a host.
|
||||
|
||||
Requirements:
|
||||
|
||||
- **Auto-renew** on the standard ACME schedule (renew at ~⅓ lifetime remaining; LE guidance ~30 days before expiry). The ACME integration owns this loop.
|
||||
- **Expiry monitoring as a first-class alarm.** Emit cert-days-remaining into OTEL/Jaeger metrics (consistent with RFC-001 §8's "monitor for cert expiry — a cert lapse silently defederates"). Alarm thresholds (e.g. warn <14d, critical <3d) are **runtime-tunable** config (§5).
|
||||
- **Federation-health probe:** periodically resolve our own delegation and validate our own cert *as a peer would* (external vantage where possible), so a broken renewal is caught as "we would fail a peer's validation" before a peer notices.
|
||||
- **Federation-health probe:** periodically resolve our own delegation and validate our own cert _as a peer would_ (external vantage where possible), so a broken renewal is caught as "we would fail a peer's validation" before a peer notices.
|
||||
- **Escalation tie-in:** a critical cert-expiry or federation-health failure raises a `mosaic.escalation` (RFC-001 §4.2/§5) into the HIL room. A cert lapse is a fleet-visibility incident, not a silent config drift.
|
||||
|
||||
---
|
||||
@@ -283,48 +327,52 @@ interface SecretBackend {
|
||||
// --- static secret CRUD (appservice tokens, ACME EAB, DB creds) ---
|
||||
get(ref: SecretRef): Promise<SecretValue>;
|
||||
put(ref: SecretRef, value: SecretValue, opts?: { immutable?: boolean }): Promise<void>;
|
||||
rotate(ref: SecretRef, next: SecretValue): Promise<{ previous: SecretVersion; current: SecretVersion }>;
|
||||
rotate(
|
||||
ref: SecretRef,
|
||||
next: SecretValue,
|
||||
): Promise<{ previous: SecretVersion; current: SecretVersion }>;
|
||||
list(prefix: SecretRef): Promise<SecretRef[]>;
|
||||
delete(ref: SecretRef): Promise<void>;
|
||||
|
||||
// --- agent-credential lifecycle (the fleet-identity part) ---
|
||||
enrollAgent(input: {
|
||||
agentSlug: string;
|
||||
scope: CredentialScope; // which rooms/secrets this agent may read
|
||||
ttl?: Duration; // ephemeral-by-default per RFC-001 §8
|
||||
}): Promise<AgentCredentialHandle>; // wraps the per-agent access_token + optional pubkey record
|
||||
scope: CredentialScope; // which rooms/secrets this agent may read
|
||||
ttl?: Duration; // ephemeral-by-default per RFC-001 §8
|
||||
}): Promise<AgentCredentialHandle>; // wraps the per-agent access_token + optional pubkey record
|
||||
|
||||
revokeAgent(agentSlug: string): Promise<void>; // must be authoritative & immediate
|
||||
revokeAgent(agentSlug: string): Promise<void>; // must be authoritative & immediate
|
||||
|
||||
// --- health / bootstrap ---
|
||||
health(): Promise<BackendHealth>;
|
||||
authenticateSelf(bootstrap: BootstrapAuth): Promise<void>; // how the appservice/orchestrator logs into the backend
|
||||
authenticateSelf(bootstrap: BootstrapAuth): Promise<void>; // how the appservice/orchestrator logs into the backend
|
||||
}
|
||||
```
|
||||
|
||||
Design intent: **`get/put/rotate`** cover the static crown-jewel secrets (appservice `hs_token`/`as_token`, ACME account/EAB keys, DB DSN). **`enrollAgent/revokeAgent`** cover the *fleet-identity* lifecycle — this is where RFC-001's "mint per-agent token at enroll, discard on teardown" (RFC-001 §4.1, §8) actually lands.
|
||||
Design intent: **`get/put/rotate`** cover the static crown-jewel secrets (appservice `hs_token`/`as_token`, ACME account/EAB keys, DB DSN). **`enrollAgent/revokeAgent`** cover the _fleet-identity_ lifecycle — this is where RFC-001's "mint per-agent token at enroll, discard on teardown" (RFC-001 §4.1, §8) actually lands.
|
||||
|
||||
### 4.2 How appservice / agent tokens map onto it
|
||||
|
||||
RFC-001 defines three tiers of Matrix secret. They map cleanly:
|
||||
|
||||
| RFC-001 secret | Sensitivity | `SecretBackend` treatment |
|
||||
|---|---|---|
|
||||
| **`as_token`** (AS→HS, acts as any namespaced user) | crown jewel | `put(immutable-ish)` + `rotate`; **only** the appservice may `get` it; stored under a fleet-admin scope; never handed to an agent |
|
||||
| **`hs_token`** (HS→AS callback auth) | crown jewel | same as `as_token`; both live only in appservice scope (RFC-001 §8) |
|
||||
| **enroll bootstrap secret / orchestrator-signed nonce** | high | `get` by orchestrator + appservice only; used to authenticate `POST /enroll` so a rogue process can't enroll a rogue agent (RFC-001 §8, B5) |
|
||||
| **per-agent `access_token`** | transient runtime | minted via `enrollAgent`, scoped to that agent, **short-TTL / re-mintable**, discarded on teardown; **not** durably persisted (RFC-001 §8 says per-agent tokens are transient) — the backend may hold a short-lived handle or a personal-vault entry for the agent's own lifetime |
|
||||
| **Ed25519 signed-authorship keypair** | high (private key) | private key generated **agent-side**, only pubkey leaves the agent (RFC-001 §4.4/§8); the SecretBackend stores the **pubkey record** for audit; per-spin keys need no at-rest custody |
|
||||
| RFC-001 secret | Sensitivity | `SecretBackend` treatment |
|
||||
| ------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`as_token`** (AS→HS, acts as any namespaced user) | crown jewel | `put(immutable-ish)` + `rotate`; **only** the appservice may `get` it; stored under a fleet-admin scope; never handed to an agent |
|
||||
| **`hs_token`** (HS→AS callback auth) | crown jewel | same as `as_token`; both live only in appservice scope (RFC-001 §8) |
|
||||
| **enroll bootstrap secret / orchestrator-signed nonce** | high | `get` by orchestrator + appservice only; used to authenticate `POST /enroll` so a rogue process can't enroll a rogue agent (RFC-001 §8, B5) |
|
||||
| **per-agent `access_token`** | transient runtime | minted via `enrollAgent`, scoped to that agent, **short-TTL / re-mintable**, discarded on teardown; **not** durably persisted (RFC-001 §8 says per-agent tokens are transient) — the backend may hold a short-lived handle or a personal-vault entry for the agent's own lifetime |
|
||||
| **Ed25519 signed-authorship keypair** | high (private key) | private key generated **agent-side**, only pubkey leaves the agent (RFC-001 §4.4/§8); the SecretBackend stores the **pubkey record** for audit; per-spin keys need no at-rest custody |
|
||||
|
||||
The key blast-radius property (RFC-001 §8) is preserved: agents receive **only their own** credential via `enrollAgent`; the `as_token` never leaves appservice scope.
|
||||
|
||||
### 4.3 Vault implementation
|
||||
|
||||
Vault maps naturally:
|
||||
|
||||
- Static secrets → **KV v2** at a mount/namespace (`mosaic-fleet/`), with versioning giving `rotate` semantics for free.
|
||||
- **`enrollAgent`** → issue a scoped, TTL'd token or use **AppRole** / a scoped policy per agent; Vault's native TTL + revocation is exactly the transient per-agent model. `revokeAgent` → Vault token/lease revoke (authoritative, immediate).
|
||||
- **`authenticateSelf`** → the appservice authenticates to Vault via AppRole (role_id from config, secret_id injected at deploy) or a platform auth method; consistent with how Gateway/DB secrets are handled today (RFC-001 §8). **[VERIFY]** align with whatever KBN-101 lands for Mosaic secret management (CLAUDE.md flags secrets work in flight).
|
||||
- **Trade-off (honesty):** Vault is the most capable backend but is **not** the free-and-simple default for a hobbyist stranger; hence it must not be *forced* (G6).
|
||||
- **Trade-off (honesty):** Vault is the most capable backend but is **not** the free-and-simple default for a hobbyist stranger; hence it must not be _forced_ (G6).
|
||||
|
||||
### 4.4 Vaultwarden implementation + the org/enroll/revoke agent-account model
|
||||
|
||||
@@ -337,16 +385,16 @@ Vaultwarden (self-hostable Bitwarden-compatible server) is the **open-source-eth
|
||||
|
||||
Mapping to the interface:
|
||||
|
||||
| Interface op | Vaultwarden mechanism |
|
||||
|---|---|
|
||||
| `get/put/rotate` (static) | items in an org **collection**; rotate = new item version / replace + old-version audit |
|
||||
| `enrollAgent` | create/attach agent principal to its **collection**; provision per-agent token item, TTL enforced by our teardown (Vaultwarden itself is not TTL-native — see [VERIFY]) |
|
||||
| `revokeAgent` | revoke the agent principal's org membership / collection access |
|
||||
| `authenticateSelf` | orchestrator authenticates as the org automation principal |
|
||||
| Interface op | Vaultwarden mechanism |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `get/put/rotate` (static) | items in an org **collection**; rotate = new item version / replace + old-version audit |
|
||||
| `enrollAgent` | create/attach agent principal to its **collection**; provision per-agent token item, TTL enforced by our teardown (Vaultwarden itself is not TTL-native — see [VERIFY]) |
|
||||
| `revokeAgent` | revoke the agent principal's org membership / collection access |
|
||||
| `authenticateSelf` | orchestrator authenticates as the org automation principal |
|
||||
|
||||
**HONEST MATURITY FLAG — [VERIFY]:** Bitwarden's clean **machine-account / service-account** primitive lives in **Bitwarden Secrets Manager**, and **Vaultwarden's coverage of Secrets Manager / machine accounts is partial and evolving.** What is known to work today on Vaultwarden: **orgs, collections, per-user (incl. a per-agent "user") vaults, and collection-scoped sharing.** What **may not** be fully there: the polished **machine-account API**, native short-TTL service credentials, and fine-grained programmatic access-token issuance equivalent to hosted Bitwarden Secrets Manager. **[VERIFY]** current Vaultwarden version's Secrets Manager / machine-account support before P-level commitment.
|
||||
|
||||
**Why this is not a blocker:** the interface is designed so **either backend is viable**. If Vaultwarden's machine-account API isn't ready, the Vaultwarden adapter implements `enrollAgent` via the **personal-vault-per-agent + org-collection** model that works *today* (create an agent principal, share the scoped collection, we enforce TTL via orchestrator teardown rather than backend-native TTL). If an operator needs backend-native short-TTL machine credentials now, they choose the **Vault** adapter. **We are not blocked on Vaultwarden maturing**, because the `SecretBackend` abstraction lets the polished-machine-account behavior land later without changing any caller.
|
||||
**Why this is not a blocker:** the interface is designed so **either backend is viable**. If Vaultwarden's machine-account API isn't ready, the Vaultwarden adapter implements `enrollAgent` via the **personal-vault-per-agent + org-collection** model that works _today_ (create an agent principal, share the scoped collection, we enforce TTL via orchestrator teardown rather than backend-native TTL). If an operator needs backend-native short-TTL machine credentials now, they choose the **Vault** adapter. **We are not blocked on Vaultwarden maturing**, because the `SecretBackend` abstraction lets the polished-machine-account behavior land later without changing any caller.
|
||||
|
||||
---
|
||||
|
||||
@@ -401,33 +449,33 @@ The DB `CHECK` is a belt-and-braces backstop; the application-layer config servi
|
||||
|
||||
The single most important classification: **what can never change after install** vs **what an operator tunes anytime.** Getting `server_name` on the wrong side of this line is a foot-gun that orphans every identity.
|
||||
|
||||
| Config key | Mutability | Rationale / cost of change |
|
||||
|---|---|---|
|
||||
| `topology.mode` (A/B/C) | **install-immutable** | changing mode changes identity/federation semantics; a mode change is effectively a reinstall/re-home |
|
||||
| `topology.identity.server_name` | **install-immutable** | **baked into every MXID and room alias.** Changing it re-homes every identity — see re-home note below. **This is THE immutable value.** |
|
||||
| `topology.identity.server_name_kind` | **install-immutable** | domain↔ip change is a re-home (Mode C→A/B) |
|
||||
| `topology.homeserver.host` | install-immutable (**delegation-tunable**) | in Mode A you *can* move the host if you update delegation to match — the identity is unchanged; treat as immutable-with-migration, not free |
|
||||
| `topology.homeserver.bind_ip` / `port` | runtime-tunable (ops) | operational network binding; no identity impact |
|
||||
| `topology.delegation.method` / records | tunable-with-care | can switch well-known↔SRV as long as both still resolve to the same host; validated on change |
|
||||
| `topology.federation.enabled` | **runtime-tunable (gated)** | can flip **on** only if DNS+cert preconditions pass (§2.4); flipping standalone→federated is the §7 upgrade path |
|
||||
| `topology.federation.domain_whitelist` / `peers` | runtime-tunable | add/remove peers over time; each add re-validated |
|
||||
| `tls.acme.directory_url` / `ca_kind` | runtime-tunable | can switch CA (e.g. LE→step-ca); triggers re-issue; monitor for trust-chain change |
|
||||
| `tls.acme.challenge` | runtime-tunable | switch challenge type if DNS/HTTP reachability changes |
|
||||
| `tls.client_tls_mode` | tunable-with-care | self-signed→acme is fine; acme→self-signed weakens trust (§8) |
|
||||
| `secrets.backend` | install-immutable (**migration-only**) | switching Vault↔Vaultwarden requires a secret migration; not a live flip |
|
||||
| `secrets.connection.*` | runtime-tunable | rotate backend address/auth without changing which backend |
|
||||
| `presence.heartbeat_interval_ms` | runtime-tunable | RFC-001 §4.5 default ~30s; pure tuning |
|
||||
| `presence.miss_tolerance` | runtime-tunable | RFC-001 §4.5 default 2 |
|
||||
| `escalation.dark_threshold_min` | runtime-tunable | RFC-001 §5/§11 — default 10min → fallback |
|
||||
| `escalation.hil_threshold_min` | runtime-tunable | RFC-001 §5 — default +5min → HIL |
|
||||
| `cert.expiry_warn_days` / `expiry_critical_days` | runtime-tunable | §3.4 alarm thresholds (default 14 / 3) |
|
||||
| `federation.health_probe_interval` | runtime-tunable | §3.4 |
|
||||
| Config key | Mutability | Rationale / cost of change |
|
||||
| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `topology.mode` (A/B/C) | **install-immutable** | changing mode changes identity/federation semantics; a mode change is effectively a reinstall/re-home |
|
||||
| `topology.identity.server_name` | **install-immutable** | **baked into every MXID and room alias.** Changing it re-homes every identity — see re-home note below. **This is THE immutable value.** |
|
||||
| `topology.identity.server_name_kind` | **install-immutable** | domain↔ip change is a re-home (Mode C→A/B) |
|
||||
| `topology.homeserver.host` | install-immutable (**delegation-tunable**) | in Mode A you _can_ move the host if you update delegation to match — the identity is unchanged; treat as immutable-with-migration, not free |
|
||||
| `topology.homeserver.bind_ip` / `port` | runtime-tunable (ops) | operational network binding; no identity impact |
|
||||
| `topology.delegation.method` / records | tunable-with-care | can switch well-known↔SRV as long as both still resolve to the same host; validated on change |
|
||||
| `topology.federation.enabled` | **runtime-tunable (gated)** | can flip **on** only if DNS+cert preconditions pass (§2.4); flipping standalone→federated is the §7 upgrade path |
|
||||
| `topology.federation.domain_whitelist` / `peers` | runtime-tunable | add/remove peers over time; each add re-validated |
|
||||
| `tls.acme.directory_url` / `ca_kind` | runtime-tunable | can switch CA (e.g. LE→step-ca); triggers re-issue; monitor for trust-chain change |
|
||||
| `tls.acme.challenge` | runtime-tunable | switch challenge type if DNS/HTTP reachability changes |
|
||||
| `tls.client_tls_mode` | tunable-with-care | self-signed→acme is fine; acme→self-signed weakens trust (§8) |
|
||||
| `secrets.backend` | install-immutable (**migration-only**) | switching Vault↔Vaultwarden requires a secret migration; not a live flip |
|
||||
| `secrets.connection.*` | runtime-tunable | rotate backend address/auth without changing which backend |
|
||||
| `presence.heartbeat_interval_ms` | runtime-tunable | RFC-001 §4.5 default ~30s; pure tuning |
|
||||
| `presence.miss_tolerance` | runtime-tunable | RFC-001 §4.5 default 2 |
|
||||
| `escalation.dark_threshold_min` | runtime-tunable | RFC-001 §5/§11 — default 10min → fallback |
|
||||
| `escalation.hil_threshold_min` | runtime-tunable | RFC-001 §5 — default +5min → HIL |
|
||||
| `cert.expiry_warn_days` / `expiry_critical_days` | runtime-tunable | §3.4 alarm thresholds (default 14 / 3) |
|
||||
| `federation.health_probe_interval` | runtime-tunable | §3.4 |
|
||||
|
||||
**Identity re-home note (the cost of changing `server_name`):** because every MXID (`@mosaic_*:server_name`) and room alias (`#…:server_name`) embeds `server_name`, changing it means: every agent gets a **new identity**, all rooms must be **recreated/re-aliased**, signed-authorship pubkey records re-published, and federation peers re-pointed. There is **no in-place rename** in Matrix. Hence `server_name` is install-immutable and the installer gates it behind an explicit warning (§6.7). Changing it is a **migration/reinstall**, honestly (§7).
|
||||
|
||||
### 5.4 How the appservice / homeserver read config
|
||||
|
||||
- **Synapse (homeserver)** reads a *rendered* `homeserver.yaml`. The config service **renders** Synapse's config (server_name, listeners, `federation_domain_whitelist`, `enable_registration: false`, appservice registration path, TLS/delegation) from the DB-backed config at deploy/reconfigure time. Synapse itself is not DB-config-aware; the source of truth is the product DB, and Synapse config is a **generated artifact**. Certain Synapse values (notably `server_name`) require a **Synapse restart** and are exactly the immutable ones — reinforcing §5.3.
|
||||
- **Synapse (homeserver)** reads a _rendered_ `homeserver.yaml`. The config service **renders** Synapse's config (server_name, listeners, `federation_domain_whitelist`, `enable_registration: false`, appservice registration path, TLS/delegation) from the DB-backed config at deploy/reconfigure time. Synapse itself is not DB-config-aware; the source of truth is the product DB, and Synapse config is a **generated artifact**. Certain Synapse values (notably `server_name`) require a **Synapse restart** and are exactly the immutable ones — reinforcing §5.3.
|
||||
- **The appservice** reads config **live** from the DB config service for runtime-tunable values (thresholds, whitelist changes, cert alarm thresholds) and from the `SecretBackend` for secrets. Immutable topology values are read once at boot (they can't change under it).
|
||||
- **`packages/comms` SDK** receives the values it needs (homeserver client URL, presence intervals) from the appservice at enroll (RFC-001 §4.1 returns `{mxid, access_token, homeserver, rooms[]}`), so agents never read the config DB directly.
|
||||
- **Delegation artifacts** (`.well-known/matrix/server`, `.well-known/matrix/client`) are likewise **rendered** from config and served by the reverse proxy / homeserver.
|
||||
@@ -443,6 +491,7 @@ A guided installer (`mosaic comms install` or equivalent) that captures topology
|
||||
**6.2 — Primary instance (ALWAYS).** The PRIMARY/home instance is always configured — there is no "skip primary." Prompt for its identity. This is non-optional and is what makes standalone work out of the box.
|
||||
|
||||
**6.3 — Pick topology mode (A/B/C).** Ask the shape:
|
||||
|
||||
- Do you have a domain? **No →** Mode C (IP-only standalone); warn federation is impossible here (§2.4) and that `server_name` will be an IP (re-home cost if they later want federation).
|
||||
- Yes, and identity domain differs from the homeserver host? **Yes →** Mode A (split-domain); capture `server_name` + host + delegation method.
|
||||
- Yes, one domain does everything? **→** Mode B (single-domain).
|
||||
@@ -469,6 +518,7 @@ A guided installer (`mosaic comms install` or equivalent) that captures topology
|
||||
The operator must **retype `server_name`** to proceed — a deliberate friction gate on the one truly permanent value.
|
||||
|
||||
**6.8 — Provision & validate (no success claim until proven).** The installer then:
|
||||
|
||||
1. Renders Synapse config + delegation artifacts; brings up Synapse with `enable_registration: false`.
|
||||
2. Runs the ACME flow; obtains cert(s); verifies they're valid and installed.
|
||||
3. Authenticates to the SecretBackend; stores `hs_token`/`as_token`, enroll bootstrap; runs `health()`.
|
||||
@@ -487,14 +537,16 @@ The operator must **retype `server_name`** to proceed — a deliberate friction
|
||||
|
||||
An operator who started standalone later wants federation. The path depends on **whether they started with a domain**:
|
||||
|
||||
**Case 1 — started Mode A or B (had a domain), federation was just off.** *Cheap, no re-home.* `server_name` is already a real domain and identities are already minted against it. To federate:
|
||||
**Case 1 — started Mode A or B (had a domain), federation was just off.** _Cheap, no re-home._ `server_name` is already a real domain and identities are already minted against it. To federate:
|
||||
|
||||
1. Ensure DNS resolves for peers (their `server_name`s and yours) — likely already true.
|
||||
2. Ensure a **valid, peer-trusted cert** on the federation endpoint (if they were running client-only self-signed, they now need a real ACME cert; if already ACME, done).
|
||||
3. Set `federation.enabled = true`, populate `domain_whitelist` + `peers` (all runtime-tunable, §5.3).
|
||||
4. Re-run the installer's **federation validation** (§6.8 step 4) against each peer. On green, federation is live. **No identity change** — existing MXIDs simply become reachable cross-site. This is the intended, low-friction upgrade.
|
||||
|
||||
**Case 2 — started Mode C (IP-only), now wants federation.** *Expensive — an identity re-home, and we say so plainly.* Federation requires DNS + a valid cert (§2.4), which an IP `server_name` can never satisfy. So the operator must:
|
||||
1. **Acquire a domain** and DNS, and provision a **valid ACME cert** (LE public, or step-ca if the domain is private — but note a *private* domain can only federate with peers who trust that private CA root, §8).
|
||||
**Case 2 — started Mode C (IP-only), now wants federation.** _Expensive — an identity re-home, and we say so plainly._ Federation requires DNS + a valid cert (§2.4), which an IP `server_name` can never satisfy. So the operator must:
|
||||
|
||||
1. **Acquire a domain** and DNS, and provision a **valid ACME cert** (LE public, or step-ca if the domain is private — but note a _private_ domain can only federate with peers who trust that private CA root, §8).
|
||||
2. **Change `server_name` from the IP literal to the domain** — this is the **install-immutable value**, so this is a **re-home, not a config tweak**:
|
||||
- Every agent identity `@mosaic_*:192.168.1.50:8448` becomes `@mosaic_*:newdomain` — **all new MXIDs.**
|
||||
- Every room + alias must be **recreated** under the new `server_name`.
|
||||
@@ -502,13 +554,14 @@ An operator who started standalone later wants federation. The path depends on *
|
||||
- Any durable references to old MXIDs (escalation policies, fallback-coordinator targets, RFC-001 §5) must be re-pointed.
|
||||
3. Effectively: **treat it as a fresh install in Mode A/B with a data migration of rooms/history**, not an in-place flip. Matrix has **no in-place `server_name` rename**; this cost is intrinsic to Matrix, not to our design.
|
||||
|
||||
**Honest guidance the installer gives Mode C operators up front (§6.3):** "If there is *any* chance you'll want to federate later, start with a domain (Mode A/B) even if you keep federation off — flipping federation on later is free, but changing an IP `server_name` to a domain later is a full identity re-home." This lets an informed operator avoid the expensive path by choosing Mode B-with-federation-off instead of Mode C.
|
||||
**Honest guidance the installer gives Mode C operators up front (§6.3):** "If there is _any_ chance you'll want to federate later, start with a domain (Mode A/B) even if you keep federation off — flipping federation on later is free, but changing an IP `server_name` to a domain later is a full identity re-home." This lets an informed operator avoid the expensive path by choosing Mode B-with-federation-off instead of Mode C.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
**8.1 — Cert trust model per CA choice.**
|
||||
|
||||
- **Let's Encrypt (public):** chains to a universally-trusted root (ISRG). Peers, humans' browsers, and Element trust it with no extra distribution. Best for public domains; nothing to distribute.
|
||||
- **step-ca (private):** chains to a **root you operate**. Nothing trusts it by default. Therefore the **step-ca root must be distributed** to everyone who validates certs: peer homeservers (so cross-site S2S validates — a peer must add your root to its federation trust store, **[VERIFY]** Synapse's mechanism for trusting a custom federation CA), agent hosts, and any human client. This is the price of "total control" and airgap capability. For **federation between two private-CA sites**, both sites must trust each other's roots (or a shared root). Getting this wrong reproduces the silent-defederation failure (§3.4) — a peer that doesn't trust your root silently refuses your S2S.
|
||||
- **Mode C self-signed client TLS:** weakest — see 8.4.
|
||||
@@ -527,15 +580,15 @@ An operator who started standalone later wants federation. The path depends on *
|
||||
|
||||
RFC-002 is the **substrate**. Each RFC-001 phase consumes a subset of it. Critically, **P1 does not need the hard parts** — presence ships on a single clean-domain instance with no federation, no IP-only, and no secret-rotation story resolved.
|
||||
|
||||
| RFC-001 phase | RFC-002 pieces it NEEDS | RFC-002 pieces it does NOT need yet |
|
||||
|---|---|---|
|
||||
| **P1 — Presence (first shippable slice)** | **Mode A or B, single-instance, clean domain** (§2.3). One ACME cert (LE or step-ca) via the single integration (§3). Config system minimal: `server_name` immutable + presence thresholds tunable (§5.3). Installer path 6.1–6.3, 6.4 (cert), 6.7 (immutable gate), 6.8 (validate single-instance). A **minimal** SecretBackend just holding the appservice token. | **No federation** (§2.4 gate irrelevant — single site). **No IP-only** needed for P1 (P1 wants a clean domain so Element/humans connect cleanly). **No agent-credential rotation** maturity. **No peer/whitelist** config. Vaultwarden machine-account [VERIFY] does **not** block P1. |
|
||||
| **P2 — Appservice + auto-enroll** | Full `SecretBackend` **`enrollAgent`/`revokeAgent`** (§4.1), `hs_token`/`as_token` custody (§4.2), enroll-bootstrap secret. Config: room taxonomy, per-agent token classification. Chosen backend (Vault or Vaultwarden) real. | Federation, cross-site, IP-only. |
|
||||
| **P3 — MACP v1 spec** | Config keys for MACP versioning/thresholds as runtime-tunable (§5.3); nothing new topology-wise. | Federation, secret rotation-in-anger. |
|
||||
| **P4 — Federation** | **The whole federation half of RFC-002:** Mode A delegation (§3.3), the hard DNS+cert gate (§2.4), `domain_whitelist`+peers config, cert monitoring/silent-defederation alarm (§3.4), per-CA trust distribution for cross-site (§8.1). This is where standalone→federated (§7 Case 1) and Jason's `mosaic.woltje.com`↔`mosaic.uscllc.com` shape land. | IP-only (federation excludes it by construction). |
|
||||
| **P5 — Hardening + signed-authorship + Hermes retired** | Secret **rotation runbooks** executed in anger (§4, RFC-001 E2), pubkey-record custody for Ed25519 (§4.2), cert-rotation runbook (§3.4), full homeserver hardening validated (§8.5), backend auth review (§8.3). | — |
|
||||
| RFC-001 phase | RFC-002 pieces it NEEDS | RFC-002 pieces it does NOT need yet |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **P1 — Presence (first shippable slice)** | **Mode A or B, single-instance, clean domain** (§2.3). One ACME cert (LE or step-ca) via the single integration (§3). Config system minimal: `server_name` immutable + presence thresholds tunable (§5.3). Installer path 6.1–6.3, 6.4 (cert), 6.7 (immutable gate), 6.8 (validate single-instance). A **minimal** SecretBackend just holding the appservice token. | **No federation** (§2.4 gate irrelevant — single site). **No IP-only** needed for P1 (P1 wants a clean domain so Element/humans connect cleanly). **No agent-credential rotation** maturity. **No peer/whitelist** config. Vaultwarden machine-account [VERIFY] does **not** block P1. |
|
||||
| **P2 — Appservice + auto-enroll** | Full `SecretBackend` **`enrollAgent`/`revokeAgent`** (§4.1), `hs_token`/`as_token` custody (§4.2), enroll-bootstrap secret. Config: room taxonomy, per-agent token classification. Chosen backend (Vault or Vaultwarden) real. | Federation, cross-site, IP-only. |
|
||||
| **P3 — MACP v1 spec** | Config keys for MACP versioning/thresholds as runtime-tunable (§5.3); nothing new topology-wise. | Federation, secret rotation-in-anger. |
|
||||
| **P4 — Federation** | **The whole federation half of RFC-002:** Mode A delegation (§3.3), the hard DNS+cert gate (§2.4), `domain_whitelist`+peers config, cert monitoring/silent-defederation alarm (§3.4), per-CA trust distribution for cross-site (§8.1). This is where standalone→federated (§7 Case 1) and Jason's `mosaic.woltje.com`↔`mosaic.uscllc.com` shape land. | IP-only (federation excludes it by construction). |
|
||||
| **P5 — Hardening + signed-authorship + Hermes retired** | Secret **rotation runbooks** executed in anger (§4, RFC-001 E2), pubkey-record custody for Ed25519 (§4.2), cert-rotation runbook (§3.4), full homeserver hardening validated (§8.5), backend auth review (§8.3). | — |
|
||||
|
||||
**One-line integration statement:** P1 rides on the *smallest* slice of RFC-002 (single clean-domain instance + one cert + minimal config + minimal secret storage); the federation/IP-only/backend-maturity complexity is deferred to exactly the phases that need it (mostly P4/P5). RFC-002 therefore does not gate P1.
|
||||
**One-line integration statement:** P1 rides on the _smallest_ slice of RFC-002 (single clean-domain instance + one cert + minimal config + minimal secret storage); the federation/IP-only/backend-maturity complexity is deferred to exactly the phases that need it (mostly P4/P5). RFC-002 therefore does not gate P1.
|
||||
|
||||
---
|
||||
|
||||
@@ -546,7 +599,7 @@ Deliberately few — most topology/cert/secret decisions are resolved by Jason's
|
||||
1. **[VERIFY] IP-only `server_name` acceptance.** Does the deployed Synapse version accept an `ip:port` `server_name` and mint usable MXIDs for standalone (§2.3)? If not, Mode C uses a fabricated local domain (`mosaic.local` via local resolver) — confirm which, since it affects the re-home wording for Mode C→A/B (§7).
|
||||
2. **[VERIFY] Vaultwarden machine-account maturity.** Confirm the current Vaultwarden version's Secrets Manager / machine-account coverage (§4.4). Determines whether the Vaultwarden adapter's `enrollAgent` uses native machine accounts or the personal-vault-per-agent + collection model. Does **not** block (interface absorbs either), but sets P2 expectations.
|
||||
3. **[VERIFY] step-ca root distribution for cross-site federation.** Confirm Synapse's supported mechanism for trusting a **custom federation CA root** (§8.1) so two private-CA sites can federate. If Synapse won't easily trust a private federation CA, private-domain federation may in practice require public certs (LE) on the federation SANs even when internal traffic uses step-ca.
|
||||
4. **Default secret backend for the published installer.** Given the open-source ethos (G6), should the installer *default-suggest* Vaultwarden (free, self-hostable) while clearly offering Vault, or present them neutrally? Recommendation: suggest Vaultwarden as the zero-cost path with the maturity caveat surfaced, Vault as the "I need native short-TTL machine creds now" path. Jason to confirm the framing.
|
||||
4. **Default secret backend for the published installer.** Given the open-source ethos (G6), should the installer _default-suggest_ Vaultwarden (free, self-hostable) while clearly offering Vault, or present them neutrally? Recommendation: suggest Vaultwarden as the zero-cost path with the maturity caveat surfaced, Vault as the "I need native short-TTL machine creds now" path. Jason to confirm the framing.
|
||||
5. **Single multi-SAN cert vs two certs in Mode A** (§3.3) — operational preference for identity-domain + host coverage. Minor; validate during P4.
|
||||
6. **Reconfigure-time Synapse restart policy.** Which rendered-config changes (§5.4) require a Synapse restart vs hot-reload on the deployed version, so the config service knows when a tunable change needs a bounce. **[VERIFY]** at implementation.
|
||||
|
||||
@@ -554,7 +607,7 @@ Deliberately few — most topology/cert/secret decisions are resolved by Jason's
|
||||
|
||||
## Appendix A — Real mechanics this RFC leans on (quick reference)
|
||||
|
||||
- **`server_name`** — Synapse identity domain; the `:suffix` of every MXID/alias; install-immutable; changing it = re-home (no in-place rename). Distinct from where Synapse *listens*.
|
||||
- **`server_name`** — Synapse identity domain; the `:suffix` of every MXID/alias; install-immutable; changing it = re-home (no in-place rename). Distinct from where Synapse _listens_.
|
||||
- **Delegation** — `https://<server_name>/.well-known/matrix/server` → `{"m.server":"host:port"}` and/or `_matrix._tcp.<server_name>` **SRV**; how identity-domain ≠ host is reconciled (Mode A). **[VERIFY]** precedence on deployed Synapse.
|
||||
- **`.well-known/matrix/client`** — C-S discovery so agents/Element find the homeserver host from the identity domain.
|
||||
- **`federation_domain_whitelist`** — Synapse allowlist; only listed domains federate; our hard no-public-federation boundary.
|
||||
@@ -566,4 +619,4 @@ Deliberately few — most topology/cert/secret decisions are resolved by Jason's
|
||||
- **Vault KV v2 / AppRole / lease-TTL / revoke** — the capable backend; native versioning=rotate, TTL+revoke=transient per-agent creds.
|
||||
- **Silent defederation** — a lapsed/renewal-failed federation cert causes peers to stop trusting S2S with no local error; must be monitored + alarmed (§3.4).
|
||||
|
||||
*All Matrix/ACME/secret-backend mechanics above are cited from architecture knowledge and MUST be re-verified against the actually deployed versions during implementation — every **[VERIFY]** is a checkpoint, not an assumption. Every illustrative domain (`mosaic.woltje.com`, `mosaic.uscllc.com`, `matrix.woltje.com`) is an operator-supplied example, never a product default or literal.*
|
||||
_All Matrix/ACME/secret-backend mechanics above are cited from architecture knowledge and MUST be re-verified against the actually deployed versions during implementation — every **[VERIFY]** is a checkpoint, not an assumption. Every illustrative domain (`mosaic.woltje.com`, `mosaic.uscllc.com`, `matrix.woltje.com`) is an operator-supplied example, never a product default or literal._
|
||||
|
||||
Reference in New Issue
Block a user