@lamalibre/create-lamaste 4.6.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +20 -3
- package/scripts/lamaste-priv +22 -3
- package/scripts/pin-release.js +98 -0
- package/src/index.js +8 -1
- package/src/lib/env.js +19 -0
- package/src/lib/service-config.js +7 -0
- package/src/tasks/redeploy.js +8 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -2
- package/vendor/docs/01-concepts/authorization.md +4 -0
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +28 -3
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +5 -11
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/second-domain.md +82 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +3 -3
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/domains.md +192 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +9 -0
- package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
- package/vendor/docs/04-api-reference/sites.md +5 -0
- package/vendor/docs/04-api-reference/tunnels.md +15 -10
- package/vendor/docs/04-api-reference/users.md +41 -29
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +1 -1
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/_index.json +105 -19
- package/vendor/docs/external-members-design.md +816 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/multi-domain-relay-design.md +836 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +20 -13
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/secret.js +12 -0
- package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/templates.js +80 -14
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +28 -2
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +7 -0
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/domains.js +214 -0
- package/vendor/server/src/commands/plugin-identity.js +2 -16
- package/vendor/server/src/commands/plugins.js +112 -8
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +3 -0
- package/vendor/server/src/commands/status.js +19 -0
- package/vendor/server/src/commands/tunnels.js +4 -1
- package/vendor/server/src/commands/users.js +8 -19
- package/vendor/server/src/domains.js +92 -0
- package/vendor/server/src/fail.js +21 -0
- package/vendor/server/src/oidc-client-grants.js +63 -0
- package/vendor/server/src/panel-api.js +15 -0
- package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +42 -16
- package/vendor/serverd/src/lib/app-error.js +24 -0
- package/vendor/serverd/src/lib/audience.js +12 -3
- package/vendor/serverd/src/lib/authelia.js +341 -35
- package/vendor/serverd/src/lib/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
- package/vendor/serverd/src/lib/nginx.js +318 -140
- package/vendor/serverd/src/lib/owned-groups.js +326 -0
- package/vendor/serverd/src/lib/plugin-domain.js +86 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugins.js +45 -5
- package/vendor/serverd/src/lib/registry-credential.js +39 -6
- package/vendor/serverd/src/lib/services.js +9 -1
- package/vendor/serverd/src/lib/state.js +11 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +29 -4
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
- package/vendor/serverd/src/lib/web-surface.js +49 -32
- package/vendor/serverd/src/middleware/user-access-session.js +11 -4
- package/vendor/serverd/src/routes/invite.js +15 -5
- package/vendor/serverd/src/routes/management/domains.js +154 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
- package/vendor/serverd/src/routes/management/identity.js +21 -7
- package/vendor/serverd/src/routes/management/invitations.js +33 -4
- package/vendor/serverd/src/routes/management/plugins.js +4 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +50 -11
- package/vendor/serverd/src/routes/management/tunnels.js +29 -5
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +2 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
- package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
- package/vendor/serverd/src/routes/user-access.js +13 -1
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
# Multi-Domain Relay — Design Document
|
|
2
|
+
|
|
3
|
+
> **Status:** agreed 2026-10-25 (all nine decisions as recommended); implemented in Lamaste 4.7.0 (2026-10-26), proven by the rodeo campaigns recorded under Waves.
|
|
4
|
+
> **Pilot:** `codelama.com.tr` next to `samplepark.tr` on the relay `138.68.65.228`.
|
|
5
|
+
> **Companions:** `identity-directory-design.md` (the slice 6 directory this builds on),
|
|
6
|
+
> `gatekeeper-design.md`, `herd/docs/r730-workload-plan.md` §14 (why, and the live steps).
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
One relay serves several **base domains**. A person has **one account** (one
|
|
11
|
+
password, one TOTP) in the relay's one directory, and reaches a domain only
|
|
12
|
+
when they are an explicit **member** of it.
|
|
13
|
+
|
|
14
|
+
- Code Lama's developers are members of `codelama.com.tr` and `samplepark.tr`.
|
|
15
|
+
- samplepark's users are members of `samplepark.tr` only, and never reach a
|
|
16
|
+
`codelama.com.tr` application.
|
|
17
|
+
- Adding a customer domain changes nobody's access to any other domain.
|
|
18
|
+
|
|
19
|
+
## Settled (not reopened here)
|
|
20
|
+
|
|
21
|
+
- One relay, one user directory: one account, password and TOTP per person.
|
|
22
|
+
- An explicit member list per domain.
|
|
23
|
+
- A sign-in page (`auth.<domain>`) and an Authelia session cookie per domain.
|
|
24
|
+
- Membership is rendered into Authelia's `access_control` **and** checked
|
|
25
|
+
again in Gatekeeper, which shows a refusal page instead of today's 401 login
|
|
26
|
+
loop.
|
|
27
|
+
- `authenticated` means "member of this domain"; `restricted` plus Gatekeeper
|
|
28
|
+
grants still narrows one application.
|
|
29
|
+
- Admin stays relay-wide (the mTLS admin certificate manages every domain).
|
|
30
|
+
The panel (`panel.`) and chisel (`tunnel.`) stay on the primary domain.
|
|
31
|
+
- No per-domain external identity provider yet.
|
|
32
|
+
- The shared blast radius (one droplet, nginx, panel, Gatekeeper and Authelia
|
|
33
|
+
for every domain) is accepted and documented, not engineered around.
|
|
34
|
+
|
|
35
|
+
## What exists today (4.6 base, `feat/app-plugins-slice7` `7211da5`)
|
|
36
|
+
|
|
37
|
+
| Piece | Single-domain assumption |
|
|
38
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `panel.json` | One `domain` (`server/daemon/src/lib/config.js` `ConfigSchema`). Every route that creates a hostname reads `getConfig().domain`. |
|
|
40
|
+
| Tunnels | `createTunnelLocked` (`core/lib/src/server/tunnels.ts`) builds `fqdn = ${subdomain}.${domain}` and **stores** it. Uniqueness is by bare `subdomain` (`SUBDOMAIN_IN_USE`). The nginx writers (`NginxDeps`) take `(subdomain, domain, …)` and recompute the hostname; vhost files are `lamalibre-lamaste-app-<subdomain>` / `lamalibre-lamaste-agent-panel-<subdomain>`. |
|
|
41
|
+
| Startup reconcile | `expectedTunnelVhost` (`server/daemon/src/lib/vhost-reconcile.js`) already derives a tunnel's domain from its stored `fqdn` (`fqdn.slice(subdomain.length + 1)`); `reconcileSiteVhosts(log, config.domain)` renders the panel, auth and every site vhost with the one domain. |
|
|
42
|
+
| Sites | `createSite` (`core/lib/src/server/sites.ts`): managed `fqdn = ${name}.${domain}`, custom `fqdn = customDomain`; `aliases` on custom sites. Collision checks rebuild tunnel hosts as `${t.subdomain}.${domain}` rather than reading `t.fqdn`. `createSite` does not refuse an `agent-` name (gap). Site vhosts are `lamalibre-lamaste-site-<uuid>`. |
|
|
43
|
+
| Web surfaces (slice 7) | `createWebSurface` / `hostnameTaken` (`server/daemon/src/lib/web-surface.js`) build `${subdomain}.${config.domain}`. |
|
|
44
|
+
| User surfaces (slice 6) | `userSurfaceHosts` (`core/lib/src/user-surface.ts`) works from stored `fqdn`s only — no domain involved. |
|
|
45
|
+
| Reserved names | `CORE_SUBDOMAINS = ['panel','auth','tunnel']`, `RESERVED_SUBDOMAINS` (+ `www`, `mail`, `ftp`, `api`), `AGENT_PANEL_SUBDOMAIN_PREFIX = 'agent-'` (`core/lib/src/constants.ts`); a copy in `server/ui/src/admin/lib/web-surface.js`. |
|
|
46
|
+
| Authelia session | `writeAutheliaConfig(domain, …)` (`core/lib/src/server/authelia.ts`) renders one `session.cookies` entry; `rewriteAutheliaConfig` already loops over the array. |
|
|
47
|
+
| Authelia `access_control` | `updateAccessControl(sites)` renders only per-site rules (allowed users, `group:admins`, deny); `default_policy: two_factor` — any Authelia user passes on every other host. |
|
|
48
|
+
| nginx 401 | Gatekeeper tunnel vhosts (`buildGatekeeperVhost`) and web-surface vhosts (`renderWebSurfaceVhost`) write `error_page 401 =302 https://auth.${domain}/?rd=…`. Static sites and the auth vhost use Authelia's own `$redirection_url`. |
|
|
49
|
+
| Auth vhost | `renderAuthVhost(domain)` (`server/daemon/src/lib/nginx.js`), file `lamalibre-lamaste-auth`, declares the http-level `limit_req_zone … zone=user_access_auth` and serves `/invite/`, `/api/invite/` and `/api/user-access/authorize`. No `X-Forwarded-Host` — Authelia sees `Host`. |
|
|
50
|
+
| Gatekeeper | `authorizeTunnelRequest` (`sdks/gatekeeper/src/server/routes/authz.ts`): `public`/`authenticated` → allow any Authelia user; `validateWithAuthelia` maps **every** non-200 (Authelia's 403 too) to `null` → 401 → nginx sends the browser back to the sign-in page, which (already signed in) sends it back: a loop. Session cache keyed by host already. |
|
|
51
|
+
| OIDC | `buildOidcProviderSection(domain, …)` writes no issuer; Authelia derives it per request from `X-Forwarded-Host`/`Host` (`IssuerURL()` in Authelia's `internal/middlewares/authelia_context.go`, checked against 4.39.13; pinned 4.39.28). `{domain}` in a client's `audience`/`redirectUris` expands to the one domain (`expandDomain`). Clients carry `authorization_policy` `one_factor`/`two_factor` — Authelia's `access_control` does **not** apply to OIDC authorization. |
|
|
52
|
+
| Registry credential | `registry-credential.json` stores no issuer; `obtainRegistryServiceToken` uses `autheliaIssuer(getConfig().domain)` (`server/daemon/src/lib/registry-credential.js` `oidcDeps`). |
|
|
53
|
+
| Certificates | HTTP-01 per hostname via `lamaste-certbot`; a wildcard is only ever found. `issueTunnelCert` strips the first label to look for `*.<base>`; `getCertPath(fqdn, domain)` is called with `config.domain`. |
|
|
54
|
+
| `lamaste-priv` | `PANEL_SITE_RE` accepts `panel-domain`, `auth`, `tunnel`, `app-<label>`, `agent-panel-<label>`, `site-<uuid>` — **no dots**, so no name containing a hostname. |
|
|
55
|
+
| Agents | Chisel and converge depend only on ports and `https://tunnel.<enrolled domain>:443` (`assertRelayServerUrl`, `enrolledDomain`). `agent-config` returns `{port, subdomain}` per tunnel. `lamaste-agent status` prints `${t.subdomain}.${config.domain}` — wrong for a tunnel on another domain. |
|
|
56
|
+
| Invitations, user access | Invite URL `https://auth.${config.domain}/invite/<token>`; desktop user login opens `auth.<d>` and exchanges at `panel.<d>` for the same `d` (`user_access.rs`). |
|
|
57
|
+
|
|
58
|
+
## Model
|
|
59
|
+
|
|
60
|
+
### Domain records
|
|
61
|
+
|
|
62
|
+
`state.db` migration `0016_domains.sql`:
|
|
63
|
+
|
|
64
|
+
```sql
|
|
65
|
+
CREATE TABLE domains (
|
|
66
|
+
name TEXT PRIMARY KEY, -- 'codelama.com.tr', lower case
|
|
67
|
+
is_primary INTEGER NOT NULL DEFAULT 0, -- exactly one row = 1
|
|
68
|
+
status TEXT NOT NULL CHECK (status IN ('pending','active')),
|
|
69
|
+
created_at TEXT NOT NULL,
|
|
70
|
+
activated_at TEXT
|
|
71
|
+
);
|
|
72
|
+
CREATE UNIQUE INDEX domains_one_primary ON domains(is_primary) WHERE is_primary = 1;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- The migration inserts the primary from `panel.json` `domain` (status
|
|
76
|
+
`active`) when onboarding is completed; onboarding inserts it otherwise.
|
|
77
|
+
`panel.json` `domain` stays as the primary's name, so everything that means
|
|
78
|
+
"the panel's domain" (CORS, `panel.`, `tunnel.`, agent-config, agent panels,
|
|
79
|
+
desktop user access, 2FA) keeps reading it unchanged.
|
|
80
|
+
- **Domains never nest**: a new domain may not equal, contain or be contained
|
|
81
|
+
in another (`a.samplepark.tr` beside `samplepark.tr` is refused). A browser
|
|
82
|
+
sends a parent domain's cookie to every host under it, so nesting would
|
|
83
|
+
mix two domains' sessions.
|
|
84
|
+
- Core `domains.ts` (root export, pure): `assertDomainName` (two or more
|
|
85
|
+
labels, `HOSTNAME_REGEX`, no wildcard, lower case), `domainsOverlap`,
|
|
86
|
+
`domainOfHost(host, domains)` — the one domain the host equals or is under,
|
|
87
|
+
else `null`.
|
|
88
|
+
|
|
89
|
+
**The domain of a hostname is derived from the hostname**, never stored a
|
|
90
|
+
second time. Tunnels and sites already store `fqdn`; because domains never
|
|
91
|
+
nest, `domainOfHost(fqdn)` is unambiguous. A stored second field could only
|
|
92
|
+
disagree with the hostname.
|
|
93
|
+
|
|
94
|
+
### Hostnames
|
|
95
|
+
|
|
96
|
+
| Object | Hostname | Domain |
|
|
97
|
+
| ------------------------------ | ------------------------------------------------------------- | ------------------------------------------- |
|
|
98
|
+
| App / plugin tunnel | `<subdomain>.<domain>` (`domain` in the API, default primary) | chosen at creation |
|
|
99
|
+
| Agent panel tunnel | `agent-<label>.<primary>` | always primary (mTLS, admin/agent only) |
|
|
100
|
+
| Managed site | `<name>.<domain>` | chosen at creation |
|
|
101
|
+
| Custom site | `customDomain` | `domainOfHost(customDomain)`, may be `null` |
|
|
102
|
+
| Site alias | as given | none of its own — a 301 to the site |
|
|
103
|
+
| User-surface binding (slice 6) | the bound tunnel's `fqdn` | the tunnel's; must be the plugin's domain |
|
|
104
|
+
| Web surface (slice 7) | `<subdomain>.<plugin's domain>` | the plugin's |
|
|
105
|
+
| `panel.`, `tunnel.` | primary only | |
|
|
106
|
+
| `auth.` | one per active domain | |
|
|
107
|
+
|
|
108
|
+
Changes in core and serverd:
|
|
109
|
+
|
|
110
|
+
- `createTunnel` takes `domain` and validates it against the active domains;
|
|
111
|
+
uniqueness becomes **by `fqdn`** (`feria.samplepark.tr` and
|
|
112
|
+
`feria.codelama.com.tr` may coexist). `sites.ts` collision checks and
|
|
113
|
+
`validateAliases` read stored `t.fqdn` instead of rebuilding
|
|
114
|
+
`${t.subdomain}.${domain}`; `createSite` refuses an `agent-` name (closes
|
|
115
|
+
today's gap).
|
|
116
|
+
- The `NginxDeps` writers take `fqdn` instead of `(subdomain, domain)`;
|
|
117
|
+
`nginx.js` derives the Authelia host from it (`auth.${domainOfHost(fqdn)}`).
|
|
118
|
+
`getCertPath` / `hasWildcardCert` are called with the host's domain, not
|
|
119
|
+
`config.domain`.
|
|
120
|
+
- `reconfigureTunnelLocked`, `rewriteServedRoutes`, `bindUserSurface`,
|
|
121
|
+
`web-surface.js` (`hostnameTaken`, `createWebSurface`, `renderOwnedSite`,
|
|
122
|
+
`reconcileOwnedSiteVhost`, `moveWebSurfaceFromTunnel`) and
|
|
123
|
+
`reconcileSiteVhosts` stop taking `config.domain` for hostnames.
|
|
124
|
+
- `agent-config` tunnels gain `fqdn`; `lamaste-agent status` prints it.
|
|
125
|
+
- Moving an existing tunnel to another domain is **not** in this feature
|
|
126
|
+
(decision 6): delete and re-create.
|
|
127
|
+
|
|
128
|
+
### Reserved names per domain
|
|
129
|
+
|
|
130
|
+
On **every** domain: `RESERVED_SUBDOMAINS` (`panel`, `auth`, `tunnel`, `www`,
|
|
131
|
+
`mail`, `ftp`, `api`) and the `agent-` prefix. `panel.` and `tunnel.` are not
|
|
132
|
+
served on added domains, but reserving them keeps every domain's namespace the
|
|
133
|
+
same and leaves room. A domain's apex is no relay host: it may be a custom
|
|
134
|
+
site (the company page may later move to the relay) or a site's alias, as
|
|
135
|
+
may `www.<domain>`; `isRelayHost` (`panel.`, `auth.`, `tunnel.`, `agent-*`)
|
|
136
|
+
is what custom sites and aliases may never take. Site **names** stay unique
|
|
137
|
+
relay-wide (agents' `allowedSites` and the CLI name sites by them), so a
|
|
138
|
+
second `blog.` on another domain is a custom site there. The UI's copy of the list
|
|
139
|
+
(`server/ui/src/admin/lib/web-surface.js`) is replaced by the core export.
|
|
140
|
+
|
|
141
|
+
### Vhost files named by full hostname
|
|
142
|
+
|
|
143
|
+
| Today | New |
|
|
144
|
+
| ------------------------------------------------------------------------ | -------------------------------------- |
|
|
145
|
+
| `lamalibre-lamaste-app-<subdomain>` | `lamalibre-lamaste-app-<fqdn>` |
|
|
146
|
+
| `lamalibre-lamaste-agent-panel-<sub>` | `lamalibre-lamaste-agent-panel-<fqdn>` |
|
|
147
|
+
| `lamalibre-lamaste-auth` | `lamalibre-lamaste-auth-<domain>` |
|
|
148
|
+
| `lamalibre-lamaste-site-<uuid>`, `-panel-domain`, `-tunnel`, `-panel-ip` | unchanged |
|
|
149
|
+
|
|
150
|
+
- `lamaste-priv` `PANEL_SITE_RE` gains `app-<host>`, `agent-panel-<host>`,
|
|
151
|
+
`auth-<host>` with `HOST` = the lower-case hostname rule it already applies
|
|
152
|
+
to `server_name`, and keeps accepting the legacy names for `remove`/`disable`
|
|
153
|
+
only (`LEGACY_SITE_RE`). The `lamaste-priv` test checks both.
|
|
154
|
+
- **Migration** at serverd start, in `vhost-reconcile.js`, before the panel
|
|
155
|
+
listens: for each tunnel and the auth vhost, if the legacy file exists and
|
|
156
|
+
the new one does not, write the new file with the same link state, remove
|
|
157
|
+
the legacy file and link, then one `nginx -t` + reload; a failing `nginx -t`
|
|
158
|
+
restores the legacy files. Idempotent; logged as one line per file.
|
|
159
|
+
- Ordering: create-lamaste's redeploy installs `lamaste-priv` before serverd
|
|
160
|
+
starts, so the relay update (Panel Update = redeploy) brings the new
|
|
161
|
+
allow-list first. A serverd that finds a `lamaste-priv` without the new
|
|
162
|
+
names (refused write) keeps the legacy names and logs an error.
|
|
163
|
+
- The auth vhost's http-level `limit_req_zone user_access_auth` stays only in
|
|
164
|
+
the **primary** auth vhost, with `/api/user-access/authorize`: desktop user
|
|
165
|
+
access is primary-only (the desktop exchanges at `panel.<d>` for the same
|
|
166
|
+
`d`). Added domains' auth vhosts carry `/`, `/invite/` and `/api/invite/`.
|
|
167
|
+
|
|
168
|
+
## Authelia
|
|
169
|
+
|
|
170
|
+
### Session cookies
|
|
171
|
+
|
|
172
|
+
One `session.cookies` entry per **active** domain, same `name`
|
|
173
|
+
(`authelia_session` — browsers scope cookies by domain, and every Lamaste
|
|
174
|
+
reader strips or parses by name, `authelia-session.ts`):
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
session:
|
|
178
|
+
cookies:
|
|
179
|
+
- domain: samplepark.tr
|
|
180
|
+
authelia_url: https://auth.samplepark.tr
|
|
181
|
+
default_redirection_url: https://samplepark.tr
|
|
182
|
+
- domain: codelama.com.tr
|
|
183
|
+
authelia_url: https://auth.codelama.com.tr
|
|
184
|
+
default_redirection_url: https://codelama.com.tr
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Core `renderSessionCookies(domains)` (pure); `updateAuthelia…` writes it
|
|
188
|
+
through `rewriteAutheliaConfig`. Authelia picks a cookie domain by suffix of
|
|
189
|
+
the target host (`GetCookieDomainFromTargetURI`) and only redirects to a target
|
|
190
|
+
inside a configured cookie domain (`IsSafeRedirectionTargetURI`), so each
|
|
191
|
+
domain's sign-in sets and reads its own cookie. A developer signs in once per
|
|
192
|
+
domain, with the same password and TOTP. (WebAuthn credentials would be per
|
|
193
|
+
domain — Lamaste uses TOTP only.)
|
|
194
|
+
|
|
195
|
+
### `access_control`
|
|
196
|
+
|
|
197
|
+
Authelia evaluates rules in order and applies the first that matches, so a
|
|
198
|
+
member gate cannot be a rule placed "before" the per-site rules; it has to be
|
|
199
|
+
**inside** them. `renderAccessControl(domains, members, sites)` (core, pure)
|
|
200
|
+
replaces `updateAccessControl`'s rule building:
|
|
201
|
+
|
|
202
|
+
1. **Per protected site** (as today), every subject ANDed with membership of
|
|
203
|
+
the site's domain: `allowedUsers ∩ members(domain)` as `user:` subjects;
|
|
204
|
+
the admins clause becomes `[['group:admins', 'user:<m>'] for m in members]`
|
|
205
|
+
(AND in Authelia's subject syntax — an admin who is a member). Then `deny`.
|
|
206
|
+
2. **Per domain**: `domain: [<d>, '*.<d>']`, `policy: two_factor`,
|
|
207
|
+
`subject: ['user:<m>' for m in active members]`; then
|
|
208
|
+
`domain: [<d>, '*.<d>']`, `policy: deny`. A domain with no members
|
|
209
|
+
renders only the deny.
|
|
210
|
+
3. `default_policy: deny` (today `two_factor`). Every host the relay gates is
|
|
211
|
+
under a domain; a host under none (a custom site outside every domain)
|
|
212
|
+
cannot hold a session cookie anyway, so this only closes what was
|
|
213
|
+
accidentally open.
|
|
214
|
+
|
|
215
|
+
Members are rendered by **username** (Authelia's subject), active users only
|
|
216
|
+
(a disabled or removed user is absent). Rendering is called from
|
|
217
|
+
`changeUsers` (after the directory settles, before the Authelia restart it
|
|
218
|
+
already does), from every membership change, from site changes (core `sites.ts` calls its
|
|
219
|
+
`renderAccess` dependency with the sites it saves) and at start. One `rewriteAutheliaConfig` per
|
|
220
|
+
change; unchanged output = no restart.
|
|
221
|
+
|
|
222
|
+
Membership is **not** rendered as Authelia groups in `users.yml` (decision
|
|
223
|
+
8): groups would reach every application in `Remote-Groups` and the OIDC
|
|
224
|
+
`groups` claim (Feria sees groups), and `users.yml` is a file Authelia's
|
|
225
|
+
password reset rewrites — the directory would keep a second copy of
|
|
226
|
+
membership.
|
|
227
|
+
|
|
228
|
+
### `auth.<domain>` vhost and certificate
|
|
229
|
+
|
|
230
|
+
`renderAuthVhost(domain, { primary })`: the same Authelia proxy as today,
|
|
231
|
+
`server_name auth.<domain>`, certificate `auth.<domain>` (HTTP-01). Written
|
|
232
|
+
when a domain is activated, removed when it is removed.
|
|
233
|
+
|
|
234
|
+
## Membership in the directory
|
|
235
|
+
|
|
236
|
+
`0016_domains.sql` also creates:
|
|
237
|
+
|
|
238
|
+
```sql
|
|
239
|
+
CREATE TABLE domain_members (
|
|
240
|
+
domain TEXT NOT NULL REFERENCES domains(name),
|
|
241
|
+
user_id TEXT NOT NULL REFERENCES directory_users(user_id),
|
|
242
|
+
added_at TEXT NOT NULL,
|
|
243
|
+
added_by TEXT NOT NULL, -- actor, as audit rows name it
|
|
244
|
+
seq INTEGER NOT NULL DEFAULT 0,
|
|
245
|
+
PRIMARY KEY (domain, user_id)
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- A trigger stamps `seq` from `directory_counter` on insert (as
|
|
250
|
+
`plugin_audience_stamp_insert`), and a delete trigger bumps the counter, so
|
|
251
|
+
membership changes are ordered in the directory's one sequence.
|
|
252
|
+
- Audit: `directory.membership.added` / `directory.membership.removed`
|
|
253
|
+
(`{ domain, userId, reason }` — no username), `domain.added`,
|
|
254
|
+
`domain.activated`, `domain.removed`.
|
|
255
|
+
- Keyed by `user_id`: a re-created username is a new user with no membership.
|
|
256
|
+
- **Offboarding** (`offboardUserUnlocked`): `deleted` removes every
|
|
257
|
+
membership (reason `deleted`); erasure finds none left. **Disable keeps
|
|
258
|
+
memberships** (decision 2) — a disabled user is excluded from every
|
|
259
|
+
rendering at once, and enabling restores exactly what they had.
|
|
260
|
+
- Losing membership of the domain the registry credential names also revokes
|
|
261
|
+
that user's registry tokens (`revokeUserRegistryTokens`), as losing a
|
|
262
|
+
`feria-*` group does.
|
|
263
|
+
- **New users** (decision 1): while the relay has one domain, every creation
|
|
264
|
+
path (users API, invitation accept, onboarding, a user adopted from
|
|
265
|
+
`users.yml` by `reconcileDirectory`) adds membership of it — a single-domain
|
|
266
|
+
relay behaves as today. With two or more domains, the users API and
|
|
267
|
+
invitations name the domains (`domains: [...]`, at least one); a user
|
|
268
|
+
adopted from `users.yml` gets none until an admin adds one.
|
|
269
|
+
- Invitations carry `domains`; accepting adds them in the same `changeUsers`;
|
|
270
|
+
the link is `https://auth.<first domain>/invite/<token>`.
|
|
271
|
+
- **Plugin audiences** (slice 6): a plugin belongs to one domain (below); its
|
|
272
|
+
audience is the entitled users who are members of it. A member leaving
|
|
273
|
+
becomes `remove/unentitled` — no new reason, so 4.5 agent halves need no
|
|
274
|
+
change.
|
|
275
|
+
|
|
276
|
+
## Gatekeeper
|
|
277
|
+
|
|
278
|
+
Gatekeeper reads a projection the panel writes, `domains.json` (0600
|
|
279
|
+
`lamaste`, atomic, in the data dir, watched like `tunnels.json`):
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{ "v": 1, "domains": [{ "name": "codelama.com.tr", "members": ["onur", "…"] }] }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Active domains, active members, usernames (Authelia's answer names a
|
|
286
|
+
username). Written in the same places as the Authelia rendering, before the
|
|
287
|
+
Authelia restart.
|
|
288
|
+
|
|
289
|
+
In `authenticatedSession` (every route: tunnel, user surface, shell):
|
|
290
|
+
|
|
291
|
+
1. Authelia **403** is no longer "unauthenticated": `validateWithAuthelia`
|
|
292
|
+
returns `{ kind: 'forbidden' }` and the route answers 403 with the
|
|
293
|
+
**domain refusal page** (user-surface API: JSON `{error:'not_a_member'}`).
|
|
294
|
+
401 stays for no/bad session.
|
|
295
|
+
2. After Authelia's 200, `domainOfHost(host)` and membership of the
|
|
296
|
+
username are checked against `domains.json`; not a member → the same 403.
|
|
297
|
+
A host under no domain, or a missing/unreadable projection → 403 (fail
|
|
298
|
+
closed).
|
|
299
|
+
3. Then the access mode, as today (`restricted` → grants).
|
|
300
|
+
|
|
301
|
+
The refusal page (`templates.ts` `buildDomainRefusalPage(domain, options)`)
|
|
302
|
+
says the account is not a member of `<domain>`, names the admin contact from
|
|
303
|
+
Gatekeeper's settings, and links to `https://auth.<domain>/logout` to sign in
|
|
304
|
+
as someone else. It names no username (Authelia's 403 carries none).
|
|
305
|
+
nginx already serves Gatekeeper's 403 body inline on tunnel and web-surface
|
|
306
|
+
vhosts (`error_page 403 = /internal/lamaste/authz`, `…/shell-authz`); static
|
|
307
|
+
Authelia-protected sites, which ask Authelia directly, gain an
|
|
308
|
+
`error_page 403` location that asks Gatekeeper for the page
|
|
309
|
+
(`GET /authz/refusal/:fqdn`, no session needed, nothing but the page).
|
|
310
|
+
|
|
311
|
+
The session cache key already includes the host; membership changes bust
|
|
312
|
+
it (`bustCachedSessions`, as group/grant writes do).
|
|
313
|
+
|
|
314
|
+
## OIDC: one issuer per domain
|
|
315
|
+
|
|
316
|
+
- Authelia derives the issuer from the request host, so
|
|
317
|
+
`https://auth.codelama.com.tr` is an issuer as soon as its auth vhost
|
|
318
|
+
exists; nothing in the OIDC section names an issuer. Every client is known
|
|
319
|
+
to every issuer (Authelia has no per-host client list): a token for a
|
|
320
|
+
client obtained at the wrong issuer carries that issuer's `iss`, which a
|
|
321
|
+
relying party that trusts one issuer (Feria) refuses.
|
|
322
|
+
- **A plugin belongs to one domain** (decision 5): `plugins.domain`
|
|
323
|
+
(migration 0016, default primary). It determines `{domain}` expansion in its
|
|
324
|
+
OIDC clients' `audience`/`redirectUris`, which tunnels may be bound as its
|
|
325
|
+
user surface, its web surface's domain, and its audience. Changing it is
|
|
326
|
+
refused while a surface is bound.
|
|
327
|
+
- **Membership for OIDC**: Authelia's `access_control` does not apply to OIDC
|
|
328
|
+
authorization; a client's `authorization_policy` does. For each domain with
|
|
329
|
+
user-facing clients, render `identity_providers.oidc.authorization_policies`
|
|
330
|
+
`lamaste-<n>-one_factor` / `-two_factor` (`default_policy: deny`, one rule
|
|
331
|
+
with the factor and the members as `user:` subjects), and point each
|
|
332
|
+
user-facing client at its domain's policy of its declared factor. Pure
|
|
333
|
+
`client_credentials` clients (no user) keep `one_factor` (Authelia 4.39
|
|
334
|
+
refuses anything else). `feria login` therefore admits only members of
|
|
335
|
+
Feria's domain.
|
|
336
|
+
- **Registry credential**: `registry-credential.json` gains `issuerDomain`
|
|
337
|
+
(absent = primary, written on the next save); `oidcDeps` uses it for
|
|
338
|
+
`X-Forwarded-Host`, `feriaRulesHint` names it, and `PUT` takes it
|
|
339
|
+
(`--issuer-domain` in the CLI). The owner Feria records becomes
|
|
340
|
+
`ci:auth.<issuerDomain>/lamaste-server`.
|
|
341
|
+
- **Feria move (§14.9)**, once released: install/move Feria's plugin domain to
|
|
342
|
+
`codelama.com.tr`, delete/re-create its tunnel there, update Feria's
|
|
343
|
+
`auth.json` (issuer `https://auth.codelama.com.tr`, audience
|
|
344
|
+
`https://feria.codelama.com.tr`, workloads rule), re-register the
|
|
345
|
+
credential with `issuerDomain: codelama.com.tr`. Lamaste needs nothing more.
|
|
346
|
+
|
|
347
|
+
## Domain lifecycle (panel API)
|
|
348
|
+
|
|
349
|
+
All admin-only (mTLS), new reserved API prefix `domains`:
|
|
350
|
+
|
|
351
|
+
| Route | Does |
|
|
352
|
+
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `GET /api/domains` | domains with status, member count, what uses each (tunnels, sites, plugins) |
|
|
354
|
+
| `POST /api/domains {name}` | adds `pending` (validation, no nesting) |
|
|
355
|
+
| `POST /api/domains/:name/verify-dns` | `auth.<d>` resolves to the relay IP; reports whether a wildcard probe (`test-lamaste-check.<d>`) does |
|
|
356
|
+
| `POST /api/domains/:name/activate` | issue `auth.<d>`, write its vhost, add the session cookie, render `access_control`/OIDC policies and `domains.json`, restart Authelia — one operation with rollback |
|
|
357
|
+
| `DELETE /api/domains/:name` | refused for the primary and while any tunnel, site or plugin uses it (409 `domain_in_use`); removes memberships (reason `domain-removed`), cookie, vhost |
|
|
358
|
+
| `GET /api/domains/:name/members` | `[{ userId, username, status, addedAt }]` |
|
|
359
|
+
| `PUT` / `DELETE /api/domains/:name/members/:user` (`userId` or username) | add / remove one member |
|
|
360
|
+
|
|
361
|
+
Plus: `domain` on `POST /api/tunnels`, `POST /api/sites` (managed),
|
|
362
|
+
`POST /api/plugins/:name/web-surface`; `PUT /api/plugins/:name/domain`;
|
|
363
|
+
`domains` on `POST /api/users` and invitations; `GET /api/users` adds each
|
|
364
|
+
user's `domains`.
|
|
365
|
+
|
|
366
|
+
## Panel UI, desktop, CLI
|
|
367
|
+
|
|
368
|
+
- **Panel**: a Domains page (add, DNS check with the records to create, activate,
|
|
369
|
+
remove, members); a domain picker on tunnel/site/web-surface creation
|
|
370
|
+
(hidden while there is one domain); domain chips and an editor on the
|
|
371
|
+
Users page; domains on invitations; the plugin card shows and changes the
|
|
372
|
+
plugin's domain; hostnames everywhere from stored `fqdn`.
|
|
373
|
+
- **AdminClient** (`core/lib/src/types.ts`): `getDomains`, `addDomain`,
|
|
374
|
+
`verifyDomainDns`, `activateDomain`, `removeDomain`, `getDomainMembers`,
|
|
375
|
+
`addDomainMember`, `removeDomainMember`, `setPluginDomain`; the web client
|
|
376
|
+
(`web-admin-client.js`) and the desktop client (`desktop-admin-client.js`,
|
|
377
|
+
`admin_*` commands in `admin_commands.rs`) implement them — enforced at
|
|
378
|
+
build time as today.
|
|
379
|
+
- **CLI** `lamaste-server`: `domains list|add|verify-dns|activate|remove`,
|
|
380
|
+
`domains members <d> [add|remove <userId|username>]`, `--domain` on
|
|
381
|
+
`tunnels create`, `sites create`, `plugins web-surface create`,
|
|
382
|
+
`plugins domain <name> [<d>]`, `users create --domains`; `status` lists the
|
|
383
|
+
domains. `lamaste-agent status` prints `fqdn`.
|
|
384
|
+
|
|
385
|
+
## Upgrade in place
|
|
386
|
+
|
|
387
|
+
A single-domain relay on 4.6 upgrades with no change in behaviour:
|
|
388
|
+
|
|
389
|
+
1. Migration 0016: the primary domain row; **every non-removed directory user
|
|
390
|
+
becomes a member** (`added_by: 'migration'`); every plugin's domain = primary.
|
|
391
|
+
2. Start-up: vhosts renamed (above); Authelia rendering gains the
|
|
392
|
+
one-domain rule (members = everyone) and `default_policy: deny`;
|
|
393
|
+
`domains.json` written. Every host was reachable by every Authelia user and
|
|
394
|
+
still is; nothing else changes.
|
|
395
|
+
3. The registry credential keeps working (absent `issuerDomain` = primary).
|
|
396
|
+
4. Agents: nothing (ports and `tunnel.<primary>` unchanged). Old agents
|
|
397
|
+
ignore the new `fqdn` field in `agent-config`.
|
|
398
|
+
|
|
399
|
+
## Security notes
|
|
400
|
+
|
|
401
|
+
- The member gate is enforced twice, from one source (`state.db`): Authelia
|
|
402
|
+
(rendered allow-lists, deny last, default deny) and Gatekeeper (projection).
|
|
403
|
+
Either alone keeps a non-member out of tunnels and user/web surfaces;
|
|
404
|
+
static sites rely on Authelia; OIDC on the client's authorization policy.
|
|
405
|
+
- Cross-domain cookies: domains never nest and never share a registrable
|
|
406
|
+
domain (see Security review, S7), so a session cookie of one domain is
|
|
407
|
+
never sent to another's hosts, and no application of one can set one for
|
|
408
|
+
another; the backend cookie map strips `authelia_session` by name on every
|
|
409
|
+
domain.
|
|
410
|
+
- Same-site isolation between applications on one domain is unchanged
|
|
411
|
+
(slice 7's deferred note: a customer's shells on its own registrable domain
|
|
412
|
+
is now possible).
|
|
413
|
+
- Logs and audits name domains and `userId`s, never usernames.
|
|
414
|
+
|
|
415
|
+
## Security review (2026-10-25)
|
|
416
|
+
|
|
417
|
+
Three independent reviews (code, threat model, access-decision correctness)
|
|
418
|
+
of `a7ea95c..037c92c`. Each confirmed finding was fixed with a unit test that
|
|
419
|
+
fails without the fix.
|
|
420
|
+
|
|
421
|
+
| # | Finding | Fix | Test |
|
|
422
|
+
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
423
|
+
| S1 | A protected static site under no active domain (possible from 4.6) was rewritten **public** on its next edit or DNS check: `renderStaticSiteVhost` emitted no `auth_request` with a null domain. | Such a site renders closed (`location / { return 403; }`); `updateSite` refuses (`NOT_UNDER_DOMAIN`) whenever the result is protected, not only on false→true; `verifyDns` refuses before resolving. Start-up logs and notifies the admins of such sites (`result.closed`). | multi-domain-core (update/verify refused, unprotect allowed), vhost-reconcile (closed render, notice) |
|
|
424
|
+
| S2 | An OIDC client could mix `client_credentials` with user grants; Authelia gives such a client `one_factor`, so any account of any domain could authorize it. | `OidcClientSchema` refuses `client_credentials` beside any other grant. | plugin-oidc (device code, authorization code, refresh token) |
|
|
425
|
+
| S3 | Registry tokens were revoked only for the credential's issuer domain, while `feria login` admits the Feria plugin's domain. | The registry's domains are defined once (`registryDomainsOf`: the issuer domain and the domain the registry's own host is under). The end of a membership revokes a user's registry tokens when they are a member of none of them; storing a credential whose domains changed (`setRegistryCredential`, under the domains lock) revokes those of every user outside the new ones — config read once, at most 4 revocations in flight. A domain the registry uses cannot be removed, so removal revokes nothing. | domains (stand-in Feria) |
|
|
426
|
+
| S4 | A projection write failure skipped Authelia; an Authelia failure left older, more permissive members; both only warnings. | Both steps always tried; the first failure of a streak is audited (`domain.render.failed`, steps only) and notified (critical), its end audited (`domain.render.recovered`). | domains (injected failures of each step) |
|
|
427
|
+
| S5 | The auth vhost passed the client's `X-Forwarded-Host`/`-Proto` to Authelia (issuer and cookie domain). | `location /` sets `Host`, `X-Forwarded-Host` (the portal's literal name) and `X-Forwarded-Proto https`. | nginx-vhosts |
|
|
428
|
+
| S6 | Races: a tunnel, site, plugin move or registry credential could land on a domain being removed (every later OIDC sync then threw); a custom site could race an add or activation for `auth.<new>`. | One section changes the set of domains (`changeDomains`: tunnel → sites → domains locks) for add, activation's re-check and status flip, and removal's usage check and deletion — certificate issuance, audits and nginx stay outside, the auth vhost of a removed domain goes under the domains lock after re-checking the name is still absent. Tunnels re-check through `assertDomainActive` under the tunnel lock, sites resolve under the hostname locks, `setPluginDomain` under them, the credential under `withActiveDomain` — one error, 400 `unknown_domain`. | multi-domain-core (tunnel refused), domains (add and removal wait for the tunnel lock; activation re-checks after the certificate; `unknown_domain`) |
|
|
429
|
+
| S7 | Sibling domains under one registrable domain (`cust1.example.net` + `cust2.example.net`) and public suffixes were accepted: cookie tossing, login CSRF, one `SameSite` site. | Core server `assertDomainRegistrable` (Public Suffix List, private section included): refuses a public suffix (`public_suffix`) and a domain sharing an existing one's registrable domain (`same_registrable_domain`). The rodeo test domains became `lamaste-second.local` etc. | multi-domain-core, domains |
|
|
430
|
+
| S8 | An interrupted vhost rename left the legacy file linked forever; a same-subdomain tunnel of another domain could take a primary's legacy file. | `renameVhosts` finishes a cut-short migration (the new file takes the old link, the old file goes); renames only for primary-domain tunnels and only when the old file's `server_name` is the tunnel's; file operations injectable. | vhost-reconcile (finished, foreign file left, nginx refusal restores) |
|
|
431
|
+
| S9 | Gatekeeper reloaded `domains.json` only on watch events; the panel never rewrites an unchanged file. | `POST /api/cache/bust` reloads it before busting; a 60 s timer reloads it too. | gatekeeper cache-bust-domains |
|
|
432
|
+
| S10 | The relay hostnames of a pending (or not yet added) domain were not protected; a custom site could take `auth.<d>` and collide at activation. | Sites check relay hosts against every domain record (`relayDomains`); `addDomain` and `activateDomain` refuse (`relay_host_taken`) while a tunnel, site or alias holds one. | multi-domain-core, domains |
|
|
433
|
+
| S11 | Removing a domain left pending invitations naming it (re-added later, for someone else, the invitation would grant it). | Decided at acceptance (`invitationDomainsNow`): an invitation grants only the domains it names that exist now and existed when it was created; nothing chases invitations on removal. Links use the first **active** domain. | domains (removed, re-added, granted nothing) |
|
|
434
|
+
| S12 | The refusal page for a host under no domain linked to `https://auth.<host>/logout` — possibly someone else's host. | No domain → the page names the host and links to no sign-in. | gatekeeper authz-domain-membership |
|
|
435
|
+
| S13 | A plugin move whose OIDC sync failed was only logged; the old domain's members stayed admitted. | The identity tick runs `syncOidcClients` every minute (compare-first); the move answers a warning. | domains (the tick writes the OIDC provider) |
|
|
436
|
+
| S14 | `.`/`..` path segments from the desktop, CLI and web client were resolved by curl/fetch (`/api/domains/../activate`). | Each client refuses a dot segment where every request passes — `buildPanelCurlRequest` (CLI), `apiFetch` (web); the desktop percent-encodes it in `url_encode`. | CLI panel-api, UI domains, cargo |
|
|
437
|
+
| S15 | `userSubject`'s error carried the refused value. | The message names no value. | multi-domain-core |
|
|
438
|
+
|
|
439
|
+
**After review of the fixes** (`/simplify`): the identity tick's OIDC sync
|
|
440
|
+
(S13) no longer runs PBKDF2 every minute — client-secret digests are
|
|
441
|
+
memoized per salt and secret (keyed by a SHA-256, never the secret) and
|
|
442
|
+
computed with async `crypto.pbkdf2`; an unreadable Authelia configuration
|
|
443
|
+
fails the sync (logged once) instead of looking "changed" every tick. Test:
|
|
444
|
+
multi-domain-core (a steady reconcile computes no digest).
|
|
445
|
+
|
|
446
|
+
**S7, agreed by the user 2026-10-25:** stricter than "domains never nest" —
|
|
447
|
+
domains must also have different registrable domains, and a public suffix
|
|
448
|
+
is never a domain. `codelama.com.tr` and `samplepark.tr` are unaffected; two
|
|
449
|
+
customers given `a.example.net` and `b.example.net` by the same owner cannot
|
|
450
|
+
both be on one relay. The check uses `tldts` 7.4.18 (MIT; its one dependency
|
|
451
|
+
`tldts-core` is MIT, no further dependencies), pinned exactly in
|
|
452
|
+
`@lamalibre/lamaste@4.7.0` and kept out of the browser-safe
|
|
453
|
+
`@lamalibre/lamaste@4.7.0/domains` (server path only:
|
|
454
|
+
`server/domain-registrable.ts`).
|
|
455
|
+
|
|
456
|
+
**Residual, documented in `01-concepts/security-model.md`:**
|
|
457
|
+
|
|
458
|
+
- **R1 — resolved, the user's decision of 2026-10-25.** Authelia does not (as
|
|
459
|
+
far as known) re-check `authorization_policy` on `refresh_token` grants, so
|
|
460
|
+
plugin clients get no refresh tokens: `OidcClientSchema` refuses the
|
|
461
|
+
`refresh_token` grant and the `offline` / `offline_access` scopes at install
|
|
462
|
+
and update; an installed plugin's manifest is read with core
|
|
463
|
+
`validateInstalledManifest` (its clients without them; a client left with no
|
|
464
|
+
grant goes) by the OIDC sync and by an update's revert
|
|
465
|
+
(`replacePluginPackage … { restoring: true }`), so a plugin installed before
|
|
466
|
+
keeps working. `renderOidcClient` throws on a refresh grant that got through.
|
|
467
|
+
A removed member keeps at most the access tokens already issued, until they
|
|
468
|
+
expire. Tests: plugin-oidc, agent-hosted-plugins, agent-half-staging.
|
|
469
|
+
- **R2.** Authelia's session store is shared across cookie domains; isolation
|
|
470
|
+
is browser cookie scoping plus membership by username.
|
|
471
|
+
- **R3.** Every membership change restarts Authelia and ends every session on
|
|
472
|
+
every domain (availability).
|
|
473
|
+
|
|
474
|
+
## Proof (rodeo)
|
|
475
|
+
|
|
476
|
+
`setup-host.sh`'s dnsmasq gains `address=/<domain>/<host ip>` for
|
|
477
|
+
`TEST_DOMAIN_2` and `TEST_DOMAIN_3`; `setup-visitor.sh` adds `auth.` and the
|
|
478
|
+
test hostnames of each to `/etc/hosts`; the certbot shim already signs any
|
|
479
|
+
name. Specs (written after slice 7's campaign, run only with the user's go;
|
|
480
|
+
what wave 4 wrote, with two splits — 48 and single-VM 35 — is under Waves):
|
|
481
|
+
|
|
482
|
+
- **three-VM 45 — developer on two domains**: domain B added and activated;
|
|
483
|
+
user `dev` member of A and B: signs in at `auth.A`, reaches an
|
|
484
|
+
`authenticated` tunnel on A; on B is sent to `auth.B`, signs in with the same
|
|
485
|
+
password and TOTP, reaches B's tunnel; a `restricted` tunnel on B without a
|
|
486
|
+
grant → access-request page; with a grant → through.
|
|
487
|
+
- **three-VM 46 — customer on one domain**: user `cust` member of B only:
|
|
488
|
+
B's tunnel works; A's `authenticated` tunnel, A's static site, A's user
|
|
489
|
+
surface → domain refusal page (403, no login loop); `feria`-style OIDC
|
|
490
|
+
device login for A's client refused, for B's admitted; removing `cust`
|
|
491
|
+
from B → refused within one render; deleting `cust` removes every
|
|
492
|
+
membership (audit).
|
|
493
|
+
- **three-VM 47 — a third domain added**: snapshot of A's and B's
|
|
494
|
+
`access_control` rules, `domains.json` members and reachability matrix;
|
|
495
|
+
add and activate C with a new member; A and B unchanged (rules byte-equal
|
|
496
|
+
apart from C's own, every earlier request answers as before).
|
|
497
|
+
- **single-VM 34 — upgrade and renames**: legacy-named vhosts planted, serverd
|
|
498
|
+
restarted: renamed with link state kept; one domain, all users members,
|
|
499
|
+
every host answers as before; `lamaste-priv` refuses a dotted name it does
|
|
500
|
+
not know and the legacy names for `write`.
|
|
501
|
+
|
|
502
|
+
## Waves
|
|
503
|
+
|
|
504
|
+
1. **Core** — done: `domains.ts` (`assertDomainName`, `domainsOverlap`,
|
|
505
|
+
`assertDomainAddable`, `domainOfHost`, `isReservedLabel`, `isRelayHost`),
|
|
506
|
+
`domains` reserved API prefix, the pure renderers `renderSessionCookies`,
|
|
507
|
+
`renderAccessControl`, `renderOidcAuthorizationPolicies`
|
|
508
|
+
(`OIDC_USER_FACTORS` shared with the manifest schema), tunnel and site
|
|
509
|
+
uniqueness by stored `fqdn`, one reserved-label rule for tunnels, sites
|
|
510
|
+
and web surfaces, `issuerDomain` on the registry credential
|
|
511
|
+
(`registryIssuer`); tests in serverd `multi-domain-core.test.js`. The
|
|
512
|
+
renderers are wired, and `updateAccessControl`'s own site loop removed,
|
|
513
|
+
in wave 2 together with membership — wiring them alone would deny every
|
|
514
|
+
user. `NginxDeps` by `fqdn` moves to wave 2 with `nginx.js`.
|
|
515
|
+
2. **serverd + Gatekeeper + lamaste-priv** — done. state.db `0016_domains.sql`
|
|
516
|
+
(`domains`, `domain_members` with seq triggers, `plugins.domain`);
|
|
517
|
+
`lib/domains.js` (records, `ensurePrimaryDomain`, membership,
|
|
518
|
+
lifecycle with rollback, `syncDomainAccess` rendering Authelia and the
|
|
519
|
+
`domains.json` projection), `lib/plugin-domain.js`, routes
|
|
520
|
+
`routes/management/domains.js`; the directory gives new users their
|
|
521
|
+
domains and ends a removed user's memberships in its transaction;
|
|
522
|
+
`changeUsers` renders before Authelia restarts; audiences are entitled ∩
|
|
523
|
+
members; OIDC clients carry their plugin's domain; vhosts by hostname
|
|
524
|
+
with `renameLegacyVhosts` at start; per-domain auth vhosts; Gatekeeper
|
|
525
|
+
(`authz.ts`: Authelia 403 kept, projection check, `buildDomainRefusalPage`,
|
|
526
|
+
`/authz/refusal/:fqdn`); `lamaste-priv` names. Tests: serverd
|
|
527
|
+
`domains.test.js`, `multi-domain-core.test.js`, Gatekeeper
|
|
528
|
+
`authz-domain-membership.test.js`, and the existing suites updated.
|
|
529
|
+
|
|
530
|
+
**Deviations, for review:**
|
|
531
|
+
- OIDC authorization policy names are `lamaste_<domain with . as _>_<factor>`,
|
|
532
|
+
not `lamaste:<domain>:<factor>`: Authelia's configuration loader
|
|
533
|
+
(koanf) splits key paths at dots. Domain labels hold no `_`, so the
|
|
534
|
+
names stay unambiguous.
|
|
535
|
+
- A web surface is created on its plugin's domain (decision 5); its
|
|
536
|
+
create request takes no `domain` (one would only have to equal it).
|
|
537
|
+
Moving the plugin (`PUT /api/plugins/:name/domain`) is refused while a
|
|
538
|
+
tunnel is bound as its user surface or it owns a web surface
|
|
539
|
+
(409 `surface_bound`).
|
|
540
|
+
- `plugins.domain` NULL means the primary; nothing is rewritten at the
|
|
541
|
+
upgrade. A plugin is installed on the primary and moved afterwards.
|
|
542
|
+
- Sign-in on a hostname under no domain is refused when a site is
|
|
543
|
+
created or protected (`NOT_UNDER_DOMAIN`, 400): no session cookie
|
|
544
|
+
reaches it. Such a site's rules render a deny alone.
|
|
545
|
+
- A static site's refusal page (`/authz/refusal/:fqdn`) cannot tell a
|
|
546
|
+
non-member from a user the site's own list leaves out (Authelia's 403
|
|
547
|
+
says neither): it says both.
|
|
548
|
+
- Members may be added to a pending domain, so an activation goes live
|
|
549
|
+
with them; pending domains are rendered nowhere.
|
|
550
|
+
- `changeUsers` skips its own Authelia restart when the domain rendering
|
|
551
|
+
restarted it already (one restart per change).
|
|
552
|
+
- An agent with `tunnels:write` may create (restricted) tunnels on any
|
|
553
|
+
active domain, as on the primary before.
|
|
554
|
+
- `renameVhosts` (nginx.js) is unit-tested through its caller only —
|
|
555
|
+
nginx.js reads `/etc/nginx`; the rename itself is proven by single-VM
|
|
556
|
+
spec 34.
|
|
557
|
+
|
|
558
|
+
**After review (`/simplify`):** one writer of what members may do —
|
|
559
|
+
`withDomainRendering` in `lib/domains.js` holds the lock under which
|
|
560
|
+
Authelia's domain access, the projection and the OIDC provider are
|
|
561
|
+
written, each reading the members inside it (before, the OIDC provider
|
|
562
|
+
could write back policies from an older read; `domains.test.js` proves
|
|
563
|
+
the order). `authenticated` and `restricted` tunnels share one vhost
|
|
564
|
+
writer (`writeGatekeeperVhost`): their vhosts are identical, Gatekeeper
|
|
565
|
+
decides by the access mode. A removed domain's `auth.` vhost goes with a
|
|
566
|
+
test and reload. Shared helpers: core `labelUnder`, `hostnameHolder`,
|
|
567
|
+
`DomainNameSchema`, `resolveHostA`, `registryIssuerDomain`,
|
|
568
|
+
`atomicWriteJSONIfChanged`, `indexDomainsProjection` (Gatekeeper indexes
|
|
569
|
+
the projection once); daemon `sendFailure` (lib/app-error.js),
|
|
570
|
+
`domainOfHostname`, `invitationSignInDomain`, `domainsOverview`.
|
|
571
|
+
`domainsForNewUser(…, { required })` answers `domains_required`.
|
|
572
|
+
Onboarding writes Authelia's first configuration with the administrator
|
|
573
|
+
as the primary's member, so Authelia starts once. The dead
|
|
574
|
+
`syncAllAccessControl` is gone; sites' `AutheliaDeps.updateAccessControl`
|
|
575
|
+
is `renderAccess`.
|
|
576
|
+
|
|
577
|
+
3. **UI, desktop, CLI, docs** — done. `AdminClient` gains `getDomains`,
|
|
578
|
+
`addDomain`, `verifyDomainDns`, `activateDomain`, `removeDomain`,
|
|
579
|
+
`getDomainMembers`, `addDomainMember`, `removeDomainMember`,
|
|
580
|
+
`setPluginDomain`, `domain` on tunnel/site creation, `domains` on users
|
|
581
|
+
and invitations (`AdminUser.domains`); web client, desktop client and
|
|
582
|
+
nine `admin_*` Tauri commands (domain names and user ids checked before
|
|
583
|
+
curl; `admin_create_user`/`admin_create_invitation` now pass the panel's
|
|
584
|
+
refusal through so `domains_required` reaches the form). Panel: a Domains
|
|
585
|
+
page (web `/domains`, desktop server section) with DNS records to create,
|
|
586
|
+
activation, members and removal showing `usedBy`; `DomainPicker` on
|
|
587
|
+
tunnel and managed-site creation and `DomainMembershipChoice` on user
|
|
588
|
+
creation, editing and invitations — both hidden on a one-domain relay;
|
|
589
|
+
domain chips on Users; `PluginDomain` on the plugin card; tunnels listed
|
|
590
|
+
by `fqdn`. UI helpers `admin/lib/domains.js` with tests; the UI's
|
|
591
|
+
reserved-label copy is kept equal to core's by a test (the browser bundle
|
|
592
|
+
does not carry the core library; `@lamalibre/lamaste@4.7.0` is a dev dependency
|
|
593
|
+
of the UI for that test). CLI `lamaste-server domains
|
|
594
|
+
list|add|verify-dns|activate|remove|members`, `plugins domain`,
|
|
595
|
+
`--domain` on `tunnels create`/`sites create`, `registry-credential set
|
|
596
|
+
--issuer-domain`, domains in `status`. Docs: `04-api-reference/domains.md`,
|
|
597
|
+
`02-guides/second-domain.md`, and the multi-domain sections of users,
|
|
598
|
+
tunnels, sites, registry-and-oidc, gatekeeper, overview, dns-and-domains,
|
|
599
|
+
authentication, authorization, security-model (the shared blast radius)
|
|
600
|
+
and nginx-configuration; CHANGELOG; CLAUDE.md.
|
|
601
|
+
|
|
602
|
+
**After review (`/simplify`):** the rules are answered by the panel and
|
|
603
|
+
core, never re-derived by a client — the UI imports reserved names and
|
|
604
|
+
ports from the browser-safe `@lamalibre/lamaste@4.7.0/domains` subpath (a real
|
|
605
|
+
dependency; no copy, no sync test); the desktop passes names through
|
|
606
|
+
(`url_encode`) and lets the panel refuse; `GET /api/domains` answers
|
|
607
|
+
`newUsersNeedDomains`; `domainsForNewUser` refuses an explicit empty list
|
|
608
|
+
(new users on a multi-domain relay start with no domain chosen);
|
|
609
|
+
`PUT /api/users/:username` takes `domains` and `setUserDomains` applies
|
|
610
|
+
the difference in one transaction (the UI sends one request); `verify-dns`
|
|
611
|
+
returns the records with their purpose; plugin views carry the resolved
|
|
612
|
+
domain name (`withPluginDomains`; `plugins.domain` NULL stays the stored
|
|
613
|
+
form); members are named by id or username in the members path. UI: one
|
|
614
|
+
`useMembershipChoice` hook, `DomainPicker` owns '' = primary, the shared
|
|
615
|
+
`Dialog.jsx` confirm instead of `window.confirm`, narrow invalidation.
|
|
616
|
+
CLI: one `call`, a table of domain subcommands, a shared `fail`,
|
|
617
|
+
`status` asks the panel in parallel with a short timeout and only while
|
|
618
|
+
it runs. Web surfaces take their plugin's domain (wave 2's deviation), so
|
|
619
|
+
no picker is offered for them.
|
|
620
|
+
|
|
621
|
+
**Deviation:** the CLI has no `users create` (users are created in the
|
|
622
|
+
panel or by invitation), so there is no `users create --domains`; domains
|
|
623
|
+
of a new user are set in the panel or with `domains members … add` after
|
|
624
|
+
it is created.
|
|
625
|
+
|
|
626
|
+
4. **Rodeo specs** — written in wave 4, run on fresh VMs on 2026-10-25 (see
|
|
627
|
+
_Rodeo: run, results_ below). In
|
|
628
|
+
`packages/tools/lamaste-rodeo`:
|
|
629
|
+
- **Infrastructure.** B and C are `test_domain_n <primary> second|third`
|
|
630
|
+
(`tests/e2e/test-domains.sh`: a registrable domain of its own —
|
|
631
|
+
`test.lamaste.local` → `lamaste-second.local`, `lamaste-third.local`;
|
|
632
|
+
siblings such as `second.lamaste.local` are refused since the security
|
|
633
|
+
review, S7),
|
|
634
|
+
the one rule, sourced by `setup-host.sh` (both suites: dnsmasq answers
|
|
635
|
+
the primary and the second, the three-VM host the third too),
|
|
636
|
+
`setup-visitor.sh` (`auth.` of each in `/etc/hosts` and the cloud-init
|
|
637
|
+
template) and `helpers.sh`, and shipped to the VMs as a tier asset. No
|
|
638
|
+
tier or test variable: the tier runner injects only `HOST_IP` and
|
|
639
|
+
`TEST_DOMAIN`. The specs make sure of the DNS again (`md_dns`,
|
|
640
|
+
`local_domain_dns`) — a VM restored from an older snapshot predates it.
|
|
641
|
+
Shared helpers: `tests/e2e/helpers.sh` (`vhost_name` /
|
|
642
|
+
`vhost_available` / `vhost_linked`, `AUTHELIA_CONFIG_JSON` and
|
|
643
|
+
`AUTHELIA_RULES_UNDER`, `start_stub_registry` — now used by 26, 27, 28 and
|
|
644
|
+
35 — and the single-VM `local_sign_in`, `local_reset_totp`,
|
|
645
|
+
`local_wait_authelia`, `local_domain_dns`) and
|
|
646
|
+
`tests/e2e-three-vm/multi-domain-harness.sh` (on top of
|
|
647
|
+
`app-plugin-harness.sh`, whose preflight is split into `relay_preflight`
|
|
648
|
+
and the app-plugin checks, whose `host_api_call` takes `API_TIMEOUT`, and
|
|
649
|
+
whose `sign_in` takes the sign-in page's domain). A user signs in again
|
|
650
|
+
within the same 30 s TOTP step only after waiting for the next one
|
|
651
|
+
(tracked per user); a fresh secret needs no wait. Membership is read
|
|
652
|
+
from `GET /api/domains/:name/members`; Gatekeeper's projection only where
|
|
653
|
+
its own check is the point.
|
|
654
|
+
- **three-VM 45 — a developer on two domains**: B added, DNS checked
|
|
655
|
+
(`auth.B` the one required record), activated, two session cookies; the
|
|
656
|
+
developer in both domains' rules and projections; signed in at `auth.A`,
|
|
657
|
+
A's tunnel; B's tunnel sends them to `auth.B` (with A's session too);
|
|
658
|
+
signed in there with the same password and TOTP secret, B's tunnel; a
|
|
659
|
+
restricted tunnel on B: the access-request page, then through with a
|
|
660
|
+
grant.
|
|
661
|
+
- **three-VM 46 — a customer on one domain**: members of B only reach B;
|
|
662
|
+
signed in at `auth.A`, A's authenticated tunnel and A's
|
|
663
|
+
Authelia-protected static site answer the domain refusal page (403, no
|
|
664
|
+
`Location`); Gatekeeper's own check — the projection held without the
|
|
665
|
+
customer (immutable, `chattr +i`, so the panel's once-a-minute render
|
|
666
|
+
cannot undo it) refuses them while Authelia still admits them; no
|
|
667
|
+
projection (a directory in its place: the panel's rename fails,
|
|
668
|
+
Gatekeeper reads none) refuses everyone; restored, they are let in;
|
|
669
|
+
every wait is `AUTHZ_SETTLE_SECONDS` (nginx keeps an authorization's
|
|
670
|
+
200 for 10 s); removed from B and signed in at `auth.B` again (the
|
|
671
|
+
removal restarted Authelia), the refusal page — audited by id; deleted,
|
|
672
|
+
every membership ends, audited `directory.membership.removed` reason
|
|
673
|
+
`deleted`, by directory id only.
|
|
674
|
+
- **three-VM 47 — a third domain**: snapshot of A's and B's rendered
|
|
675
|
+
rules, their member lists and a reachability matrix (one visitor call);
|
|
676
|
+
C added and activated with a member and a tunnel; A's and B's rules
|
|
677
|
+
byte-equal, their member lists unchanged, every request of the matrix
|
|
678
|
+
answers as before (signed in again — C's activation restarted
|
|
679
|
+
Authelia); C's member reaches C only; A's session is sent to `auth.C`.
|
|
680
|
+
- **three-VM 48 — a plugin's user surface** (split from 46: installing and
|
|
681
|
+
hosting an app plugin alone takes minutes, and rodeo ends a spec at ten):
|
|
682
|
+
a member of A with a role reaches the agent half; a customer of B,
|
|
683
|
+
signed in at `auth.A`, gets 403 JSON `{error: "not_a_member"}`, no
|
|
684
|
+
redirect; the plugin cannot move to B while served on A (409
|
|
685
|
+
`surface_bound`).
|
|
686
|
+
- **single-VM 34 — upgrade and renames**: an enabled and a disabled
|
|
687
|
+
tunnel and the auth portal put back under their 4.6 names; the panel
|
|
688
|
+
restarted renames them with link state kept, no legacy name left,
|
|
689
|
+
`nginx -t` passes, every host answers as before; one domain, the
|
|
690
|
+
primary, every user its member, `newUsersNeedDomains` false;
|
|
691
|
+
`lamaste-priv` refuses `write` for a 4.6 name and for an unknown dotted
|
|
692
|
+
name — for the name (`site name not allowed for write`), as three-VM 01
|
|
693
|
+
now asserts its content refusal names the directive. This is the nginx side of the rename that has no unit test.
|
|
694
|
+
- **single-VM 35 — OIDC sign-in by membership** (moved from 46: the
|
|
695
|
+
stand-in registry and fixture-plugin setup of spec 26 is single-VM; an
|
|
696
|
+
authorization-code client, not device code, because Authelia's
|
|
697
|
+
authorization endpoint answers a refused subject at once with
|
|
698
|
+
`error=access_denied` to the redirect URI, where a device-code flow
|
|
699
|
+
needs a browser to approve): the client belongs to A and points at A's
|
|
700
|
+
two-factor policy; signed in at `auth.A`, the developer's authorization
|
|
701
|
+
request goes on, the customer's is refused; the plugin moved to B, the
|
|
702
|
+
client points at B's policy and the customer's request at `auth.B` goes
|
|
703
|
+
on; `auth.A` and `auth.B` are issuers of their own.
|
|
704
|
+
- **Existing specs updated** for the vhost names of wave 2 (by full
|
|
705
|
+
hostname): single-VM 04 and 19, three-VM 01 (lamaste-priv's content
|
|
706
|
+
check now under a valid name — a 4.6 name is refused before the
|
|
707
|
+
content is read), 03, 15, 22, 32 and 43.
|
|
708
|
+
|
|
709
|
+
### Rodeo: run, results (2026-10-25)
|
|
710
|
+
|
|
711
|
+
Run on fresh Multipass VMs (development profile), Lamaste installed from
|
|
712
|
+
this branch: create-lamaste vendors serverd, the CLI, the UI and Gatekeeper
|
|
713
|
+
from the worktree; the core library came from the dev Feria (this branch's
|
|
714
|
+
`@lamalibre/lamaste@4.7.0` published there as 4.6.0 for the run, removed
|
|
715
|
+
afterwards).
|
|
716
|
+
|
|
717
|
+
| Suite | Specs | Result |
|
|
718
|
+
| --------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
719
|
+
| single-VM | 01, 03 (dependencies), 04, 19, 34, 35 | all pass (no skip) |
|
|
720
|
+
| three-VM | 01, 03, 15, 22, 32, 43, 45, 46, 47, 48; regression 40 | all pass after the fixes below (no skip); after the /simplify rework a second campaign on fresh VMs ran 01, 40, 43, 45, 46, 47, 48 (40 re-run after the credential-candidate fix); 40 (121 checks) and 43 (50) re-run on `8109547`, the OIDC sync planned and written under the one writer |
|
|
721
|
+
|
|
722
|
+
Fixed on the way:
|
|
723
|
+
|
|
724
|
+
- **Product — a user surface's refusal was nginx's page, not JSON.** Under
|
|
725
|
+
`auth_request` nginx keeps only the subrequest's status and headers, never
|
|
726
|
+
its body, so Gatekeeper's `{ error: 'not_a_member' }` never reached an
|
|
727
|
+
application (spec 48). Gatekeeper now names the refusal in
|
|
728
|
+
`X-Lamalibre-Refusal` (`not_a_member`, else `forbidden`) on the one
|
|
729
|
+
decision it makes; the user-surface locations capture it
|
|
730
|
+
(`auth_request_set $lamaste_refusal …`) and send a 403 to
|
|
731
|
+
`/internal/lamaste/user-surface-403`, which answers one of two fixed JSON
|
|
732
|
+
bodies (`if ($lamaste_refusal = not_a_member)`, else `forbidden`) —
|
|
733
|
+
Gatekeeper is never asked twice. `lamaste-priv` allows `default_type` (a
|
|
734
|
+
media type) in a location for it. Tests: Gatekeeper authz-user-surface,
|
|
735
|
+
serverd nginx-vhosts, lamaste-priv.
|
|
736
|
+
- **Product — Authelia was talked to before it listened.** `reloadAuthelia`
|
|
737
|
+
and `startAuthelia` slept 2 s and asked systemd whether Authelia was active
|
|
738
|
+
(it is as soon as the process is spawned). Setting the registry credential
|
|
739
|
+
to OIDC restarts Authelia to add the panel's client and then asks its
|
|
740
|
+
token endpoint: "fetch failed" (spec 43, on its second run — a race the
|
|
741
|
+
relay's frequent restarts made common). Both now wait for core
|
|
742
|
+
`autheliaReady` — `GET http://127.0.0.1:9091/api/health` answering 200,
|
|
743
|
+
30 s at most, ended at once when systemd reports the unit failed; a
|
|
744
|
+
rollback's restart waits 10 s at most, so a configuration that stays broken
|
|
745
|
+
does not hold the domains' rendering lock for a second full wait. The other
|
|
746
|
+
places that started Authelia and paused wait the same way: the Services
|
|
747
|
+
page's start/restart/reload (serverd `services.js`), a redeploy
|
|
748
|
+
(`create-lamaste`), and the rodeo reset between tests (`rodeo.hooks.js`,
|
|
749
|
+
`run-all.sh`, polling the health endpoint). Test: serverd authelia-ready.
|
|
750
|
+
- **Product — the minute's OIDC sync undid a registry credential being
|
|
751
|
+
set.** Setting the credential to OIDC syncs the panel's client into
|
|
752
|
+
Authelia, verifies the chain and only then stores the credential; the
|
|
753
|
+
identity tick's every-minute OIDC sync (security review, S13) read the
|
|
754
|
+
stored credential in between, removed the candidate's client and restarted
|
|
755
|
+
Authelia under the token request ("context canceled"; spec 40 on fresh
|
|
756
|
+
VMs). The PUT now holds its candidate (`withRegistryCandidate` in
|
|
757
|
+
`plugin-oidc.js`, one at a time) across sync, verification and store, and
|
|
758
|
+
every sync serves it. After review: a sync plans, reconciles secrets and
|
|
759
|
+
writes all under the one writer (`withDomainRendering`), as secret
|
|
760
|
+
rotation does — a sync queued behind another could otherwise write a plan
|
|
761
|
+
made before the candidate was set; the route sends its answers directly
|
|
762
|
+
and one trailing sync leaves Authelia as the stored credential says.
|
|
763
|
+
Tests: serverd agent-hosted-plugins, domains (the queued sync).
|
|
764
|
+
- **Spec harness — a TOTP code reused across a step boundary** (spec 46).
|
|
765
|
+
`md_sign_in` recorded the step before signing in; a sign-in begun at a
|
|
766
|
+
step's last second took the next step's code, so a second sign-in in that
|
|
767
|
+
step reused it and Authelia refused it ("already used this code
|
|
768
|
+
recently"). The step is now the one when the sign-in ended, and a user
|
|
769
|
+
waits while the current step is not later than it.
|
|
770
|
+
- **Spec harness — single-VM specs could not find `test-domains.sh`.** The
|
|
771
|
+
VM runner uploaded only the specs and `helpers.sh`. Fixed in rodeo
|
|
772
|
+
(`5b8db11`): a VM-runner suite uploads every `.sh` file beside its specs
|
|
773
|
+
and the suite's helpers file; `helpers.sh` sources `test-domains.sh` from
|
|
774
|
+
beside itself again (the interim `/tmp/e2e` fallback is gone).
|
|
775
|
+
- **Campaign environment — the VMs' MTU.** The orchestrator's uplink was a
|
|
776
|
+
VPN tunnel (MTU 1420) that drops ICMP "fragmentation needed" for the VMs'
|
|
777
|
+
NATed traffic: full-size TLS packets vanished (npm `ECONNRESET`, chisel's
|
|
778
|
+
GitHub download reset). Fixed in rodeo (`dce725c`), below every project:
|
|
779
|
+
when the host's default route has an MTU below Ethernet's, rodeo
|
|
780
|
+
launches each VM with that MTU (a cloud-init netplan drop-in on the
|
|
781
|
+
`default` entry). The interim `node-ready` MTU step is gone from both
|
|
782
|
+
suites.
|
|
783
|
+
|
|
784
|
+
Follow-up (a nit, not done here): one constant for Authelia's loopback base
|
|
785
|
+
URL, shared by `authelia.ts` and `registry-credential.ts`.
|
|
786
|
+
|
|
787
|
+
The three points the specs could not prove without running:
|
|
788
|
+
|
|
789
|
+
- **Authelia completes a sign-in for a host the user is not a member of.**
|
|
790
|
+
It does: a member of B only, signing in at `auth.A` for A's tunnel, passes
|
|
791
|
+
both factors (`OK OK`); the refusal comes afterwards, from Authelia's rules
|
|
792
|
+
and Gatekeeper — the domain refusal page, `403`, no redirect (specs 46,
|
|
793
|
+
48).
|
|
794
|
+
- **Spec 35's consent redirect and `access_denied`.** A member's
|
|
795
|
+
authorization request at `auth.A` is redirected on to
|
|
796
|
+
`https://auth.<A>/consent/openid/decision` (no error); a non-member's is
|
|
797
|
+
sent back to the client's redirect URI with `error=access_denied`; after the
|
|
798
|
+
plugin moves to B the same non-member's request at `auth.B` goes on, and
|
|
799
|
+
`auth.<A>` / `auth.<B>` each advertise their own issuer.
|
|
800
|
+
- **The nginx side of the vhost rename.** Spec 34 planted the 4.6 names (an
|
|
801
|
+
enabled and a disabled tunnel, the auth portal), restarted the panel: all
|
|
802
|
+
renamed by full hostname with their link state kept, no 4.6 name left as a
|
|
803
|
+
file or link, `nginx -t` passes, every host answers as before; one domain,
|
|
804
|
+
every user its member; `lamaste-priv` refuses `write` for a 4.6 name and for
|
|
805
|
+
an unknown dotted name, each for its name, and leaves no file.
|
|
806
|
+
|
|
807
|
+
## Decisions (agreed 2026-10-25)
|
|
808
|
+
|
|
809
|
+
The user agreed to every recommendation below; the alternatives are kept for the record.
|
|
810
|
+
|
|
811
|
+
1. **New users on a one-domain relay** are made members of that domain
|
|
812
|
+
automatically; with two or more domains they must be given domains.
|
|
813
|
+
_Recommended_ — keeps single-domain relays as they are. Alternative:
|
|
814
|
+
always explicit, every user creation names domains.
|
|
815
|
+
2. **Disable keeps memberships**; delete removes them. _Recommended_ — a
|
|
816
|
+
disabled user is already excluded everywhere; enable restores. Alternative:
|
|
817
|
+
disable removes them (the plan's §14.3 wording), and enable needs them
|
|
818
|
+
re-added.
|
|
819
|
+
3. **Authelia's `admins` group needs membership too**: admins get no domain
|
|
820
|
+
they are not a member of; inside a domain they keep bypassing `restricted`
|
|
821
|
+
grants. _Recommended_ — explicit everywhere. Alternative: `admins` are
|
|
822
|
+
implicit members of every domain.
|
|
823
|
+
4. **`default_policy: deny`** in Authelia from the upgrade on.
|
|
824
|
+
_Recommended_ — fail closed. Alternative: keep `two_factor`.
|
|
825
|
+
5. **A plugin belongs to one domain** (OIDC clients, user and web surfaces,
|
|
826
|
+
audience). _Recommended_. Alternative: per-client domains — no current
|
|
827
|
+
plugin needs it.
|
|
828
|
+
6. **No tunnel move between domains** in this feature; Feria's move is delete
|
|
829
|
+
- re-create (public tunnel, no grants lost). _Recommended_ — keeps scope;
|
|
830
|
+
a move operation can come later.
|
|
831
|
+
7. **Domain derived from the hostname** (no stored `domain` field), with
|
|
832
|
+
domains forbidden to nest. _Recommended_.
|
|
833
|
+
8. **Membership rendered as user subjects in `configuration.yml`**, not as
|
|
834
|
+
Authelia groups in `users.yml`. _Recommended_ (no group leak to
|
|
835
|
+
applications, one copy of membership).
|
|
836
|
+
9. **Version 4.7.0**, released after 4.6.0. _Recommended_.
|