@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.
Files changed (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-lamaste",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -43,7 +43,7 @@
43
43
  "mtls"
44
44
  ],
45
45
  "dependencies": {
46
- "@lamalibre/lamaste": "^4.6.0",
46
+ "@lamalibre/lamaste": "^4.7.0",
47
47
  "chalk": "^5.3.0",
48
48
  "execa": "^9.0.0",
49
49
  "listr2": "^8.0.0"
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { assertProductionBundle } from '../../../../scripts/assert-production-bundle.js';
16
+ import { workspaceVersions, writePinnedManifest } from './pin-release.js';
16
17
 
17
18
  const thisDir = dirname(fileURLToPath(import.meta.url));
18
19
  const packageRoot = join(thisDir, '..');
@@ -24,6 +25,10 @@ async function main() {
24
25
  await rm(vendorDir, { recursive: true, force: true });
25
26
  await mkdir(vendorDir, { recursive: true });
26
27
 
28
+ // Each vendored manifest names this release's own packages exactly
29
+ // (pin-release.js), so the relay's `npm install` never mixes releases.
30
+ const versions = await workspaceVersions(monorepoRoot);
31
+
27
32
  // --- lamaste-serverd: package.json + src/ ---
28
33
  const serverSrc = join(monorepoRoot, 'packages', 'server', 'daemon');
29
34
  if (!existsSync(serverSrc)) {
@@ -32,7 +37,11 @@ async function main() {
32
37
 
33
38
  const serverDest = join(vendorDir, 'serverd');
34
39
  await mkdir(serverDest, { recursive: true });
35
- await cp(join(serverSrc, 'package.json'), join(serverDest, 'package.json'));
40
+ await writePinnedManifest(
41
+ join(serverSrc, 'package.json'),
42
+ join(serverDest, 'package.json'),
43
+ versions,
44
+ );
36
45
  await cp(join(serverSrc, 'src'), join(serverDest, 'src'), {
37
46
  recursive: true,
38
47
  });
@@ -49,7 +58,11 @@ async function main() {
49
58
  }
50
59
  const serverCliDest = join(vendorDir, 'server');
51
60
  await mkdir(serverCliDest, { recursive: true });
52
- await cp(join(serverCliSrc, 'package.json'), join(serverCliDest, 'package.json'));
61
+ await writePinnedManifest(
62
+ join(serverCliSrc, 'package.json'),
63
+ join(serverCliDest, 'package.json'),
64
+ versions,
65
+ );
53
66
  await cp(join(serverCliSrc, 'bin'), join(serverCliDest, 'bin'), { recursive: true });
54
67
  await cp(join(serverCliSrc, 'src'), join(serverCliDest, 'src'), { recursive: true });
55
68
  console.error('Bundled vendor/server from lamaste-server CLI (package.json + bin/ + src/)');
@@ -81,7 +94,11 @@ async function main() {
81
94
  }
82
95
  const gatekeeperDest = join(vendorDir, 'gatekeeper');
83
96
  await mkdir(gatekeeperDest, { recursive: true });
84
- await cp(join(gatekeeperSrc, 'package.json'), join(gatekeeperDest, 'package.json'));
97
+ await writePinnedManifest(
98
+ join(gatekeeperSrc, 'package.json'),
99
+ join(gatekeeperDest, 'package.json'),
100
+ versions,
101
+ );
85
102
  await cp(gatekeeperDist, join(gatekeeperDest, 'dist'), { recursive: true });
86
103
  console.error('Bundled vendor/gatekeeper (package.json + dist/)');
87
104
 
@@ -81,21 +81,37 @@ class UsageError extends Error {}
81
81
  // ---------------------------------------------------------------------------
82
82
 
83
83
  const LABEL = '[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?';
84
+ /** A full hostname, lower case, two labels at least — what names a vhost since the multi-domain relay. */
85
+ const HOST = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+';
84
86
  const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
85
87
 
86
- /** Vhosts the panel writes. */
88
+ /**
89
+ * Vhosts the panel writes: the panel's and chisel's (primary domain), one
90
+ * auth portal per domain, a tunnel's by its full hostname, a site's by id.
91
+ */
87
92
  const PANEL_SITE_RE = new RegExp(
88
- `^lamalibre-lamaste-(?:panel-domain|auth|tunnel|app-${LABEL}|agent-panel-${LABEL}|site-${UUID})$`,
93
+ `^lamalibre-lamaste-(?:panel-domain|tunnel|auth-${HOST}|app-${HOST}|agent-panel-${HOST}|site-${UUID})$`,
89
94
  );
90
95
 
96
+ /**
97
+ * Vhost names before the multi-domain relay — the auth portal and tunnels
98
+ * by subdomain alone. The panel renames them at start; it may still link,
99
+ * unlink, back up, restore and remove them, but never write one.
100
+ */
101
+ const LEGACY_SITE_RE = new RegExp(`^lamalibre-lamaste-(?:auth|app-${LABEL}|agent-panel-${LABEL})$`);
102
+
91
103
  /** Vhosts create-lamaste writes that the panel may only switch on and off. */
92
104
  const TOGGLE_ONLY_SITES = new Set(['lamalibre-lamaste-panel-ip']);
93
105
 
106
+ /** The longest site name: a file name, with room for its `.bak`. */
107
+ const MAX_SITE_NAME = 250;
108
+
94
109
  function assertSiteName(name, action) {
95
- if (typeof name !== 'string' || name.length > 128) {
110
+ if (typeof name !== 'string' || name.length > MAX_SITE_NAME) {
96
111
  throw new UsageError('invalid site name');
97
112
  }
98
113
  if (PANEL_SITE_RE.test(name)) return;
114
+ if (action !== 'write' && LEGACY_SITE_RE.test(name)) return;
99
115
  if ((action === 'enable' || action === 'disable') && TOGGLE_ONLY_SITES.has(name)) return;
100
116
  throw new UsageError(`site name not allowed for ${action}: ${name}`);
101
117
  }
@@ -335,6 +351,9 @@ const RULES = {
335
351
  // rather than the host's mime.types (entries checked below).
336
352
  types: { block: 'types', args: argc(0, 0) },
337
353
  internal: { args: argc(0, 0) },
354
+ // The type of a body the location writes itself (a user surface's JSON
355
+ // refusal): a media type, never a file.
356
+ default_type: { args: (a) => a.length === 1 && /^[a-z]+\/[a-z0-9.+-]{1,64}$/.test(a[0]) },
338
357
  return: { args: argc(1, 2) },
339
358
  rewrite: { args: rewriteArgs },
340
359
  root: { args: (a) => a.length === 1 && webrootPath(a[0]) },
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pins a vendored package's dependencies on this monorepo's packages to the
3
+ * exact versions it was built with.
4
+ *
5
+ * create-lamaste vendors serverd, the server CLI and Gatekeeper, then runs
6
+ * `npm install` for them on the relay. Their manifests name the core
7
+ * library with a caret range (`^4.6.0`), so an install of release X made
8
+ * after X+1 shipped resolved X+1's core — and 4.6.0's serverd on 4.7.0's
9
+ * core did not start. Pinned, create-lamaste@X always installs the core,
10
+ * tickets and identity libraries of release X; third-party dependencies
11
+ * keep their ranges.
12
+ */
13
+
14
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
19
+
20
+ /**
21
+ * The name → version of every workspace package (`packages/<group>/<name>`).
22
+ *
23
+ * @param {string} monorepoRoot
24
+ * @returns {Promise<Map<string, string>>}
25
+ */
26
+ export async function workspaceVersions(monorepoRoot) {
27
+ const versions = new Map();
28
+ const packagesDir = join(monorepoRoot, 'packages');
29
+ for (const group of await readdir(packagesDir, { withFileTypes: true })) {
30
+ if (!group.isDirectory()) continue;
31
+ for (const entry of await readdir(join(packagesDir, group.name), { withFileTypes: true })) {
32
+ const manifestPath = join(packagesDir, group.name, entry.name, 'package.json');
33
+ if (!entry.isDirectory() || !existsSync(manifestPath)) continue;
34
+ const { name, version } = JSON.parse(await readFile(manifestPath, 'utf8'));
35
+ if (typeof name === 'string' && typeof version === 'string') versions.set(name, version);
36
+ }
37
+ }
38
+ return versions;
39
+ }
40
+
41
+ /**
42
+ * The manifest with each dependency on a workspace package set to that
43
+ * package's exact version. Pure; throws on a workspace dependency whose
44
+ * range the workspace's version does not satisfy as a caret or exact range
45
+ * (the vendored code would then be pinned to something it was not written
46
+ * against).
47
+ *
48
+ * @param {Record<string, unknown>} manifest
49
+ * @param {Map<string, string>} versions
50
+ * @returns {Record<string, unknown>}
51
+ */
52
+ export function pinWorkspaceDependencies(manifest, versions) {
53
+ const pinned = { ...manifest };
54
+ for (const field of DEPENDENCY_FIELDS) {
55
+ const deps = manifest[field];
56
+ if (deps === null || typeof deps !== 'object') continue;
57
+ const next = { ...deps };
58
+ for (const [name, range] of Object.entries(deps)) {
59
+ const version = versions.get(name);
60
+ if (version === undefined) continue;
61
+ if (range !== version && !caretAdmits(range, version)) {
62
+ throw new Error(
63
+ `${manifest.name}: ${field}.${name} is "${range}", the workspace has ${version}`,
64
+ );
65
+ }
66
+ next[name] = version;
67
+ }
68
+ pinned[field] = next;
69
+ }
70
+ return pinned;
71
+ }
72
+
73
+ /** Whether `^a.b.c` admits `version` (same major, not lower). */
74
+ function caretAdmits(range, version) {
75
+ const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(String(range));
76
+ const have = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
77
+ if (!match || !have) return false;
78
+ const [want, got] = [match.slice(1).map(Number), have.slice(1).map(Number)];
79
+ if (want[0] !== got[0]) return false;
80
+ for (let i = 1; i < 3; i++) if (got[i] !== want[i]) return got[i] > want[i];
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Copy `source` (a package.json) to `dest` with its workspace dependencies
86
+ * pinned.
87
+ *
88
+ * @param {string} source
89
+ * @param {string} dest
90
+ * @param {Map<string, string>} versions
91
+ */
92
+ export async function writePinnedManifest(source, dest, versions) {
93
+ const manifest = JSON.parse(await readFile(source, 'utf8'));
94
+ await writeFile(
95
+ dest,
96
+ `${JSON.stringify(pinWorkspaceDependencies(manifest, versions), null, 2)}\n`,
97
+ );
98
+ }
package/src/index.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync } from 'node:fs';
12
12
  import { Listr } from 'listr2';
13
13
  import chalk from 'chalk';
14
14
  import { execa } from 'execa';
15
- import { detectOS, detectIP, checkRoot } from './lib/env.js';
15
+ import { detectOS, detectIP, checkRoot, installedPanelIp } from './lib/env.js';
16
16
  import { printSummary } from './lib/summary.js';
17
17
  import { readPanelKeyPin } from './lib/panel-pin.js';
18
18
  import { hardenTasks } from './tasks/harden.js';
@@ -485,6 +485,13 @@ export async function main() {
485
485
  {
486
486
  title: 'Detecting IP address',
487
487
  task: async (_ctx, subtask) => {
488
+ // A redeploy keeps the installed relay's IP (installedPanelIp).
489
+ const installedIp = flags.forceFull ? null : await installedPanelIp();
490
+ if (installedIp) {
491
+ ctx.ip = installedIp;
492
+ subtask.output = `${installedIp} (the installed relay's, from panel.json)`;
493
+ return;
494
+ }
488
495
  ctx.ip = await detectIP({ allowPrivate: flags.dev });
489
496
  if (flags.dev) {
490
497
  subtask.output = `${ctx.ip} (dev mode — private IP accepted)`;
package/src/lib/env.js CHANGED
@@ -123,6 +123,25 @@ function isPublicIP(ip) {
123
123
  * Verify the process is running as root.
124
124
  * @throws {Error} if not root
125
125
  */
126
+ /**
127
+ * The IP an installed relay was set up with (`panel.json` `ip`), or null —
128
+ * no install, no readable file, or no valid IPv4 in it. A redeploy keeps
129
+ * it: the panel's IP certificate, agents and admins know the relay by it,
130
+ * and detecting again could change it (or fail, where the installer's
131
+ * public-IP check does not hold — a dev install on a private address).
132
+ *
133
+ * @param {string} [panelJsonPath]
134
+ * @returns {Promise<string | null>}
135
+ */
136
+ export async function installedPanelIp(panelJsonPath = '/etc/lamalibre/lamaste/panel.json') {
137
+ try {
138
+ const { ip } = JSON.parse(await readFile(panelJsonPath, 'utf8'));
139
+ return typeof ip === 'string' && isValidIPv4(ip) ? ip : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
126
145
  export function checkRoot() {
127
146
  if (process.getuid() !== 0) {
128
147
  throw new Error(
@@ -265,12 +265,19 @@ WantedBy=multi-user.target
265
265
  * Installed by create-lamaste only; onboarding writes the configuration,
266
266
  * then enables and starts it.
267
267
  *
268
+ * No start rate limit (`StartLimitIntervalSec=0`): the panel restarts
269
+ * Authelia for every change of membership, domains or OIDC clients, and
270
+ * systemd's default (5 starts in 10 s) refused the sixth of a quick series
271
+ * of admin changes — Authelia then stayed down on every domain until
272
+ * something started it again. A crash loop stays bounded by `RestartSec=5`.
273
+ *
268
274
  * @returns {string}
269
275
  */
270
276
  export function generateAutheliaUnit() {
271
277
  return `[Unit]
272
278
  Description=Authelia Authentication Server
273
279
  After=network.target
280
+ StartLimitIntervalSec=0
274
281
 
275
282
  [Service]
276
283
  Type=simple
@@ -24,6 +24,7 @@ import {
24
24
  nodeVersionTooNew,
25
25
  } from '../lib/node-version.js';
26
26
  import { deployGatekeeperPackage } from './gatekeeper.js';
27
+ import { autheliaReady } from '@lamalibre/lamaste/server';
27
28
 
28
29
  const GATEKEEPER_SERVICE = 'lamalibre-lamaste-gatekeeper';
29
30
 
@@ -447,6 +448,13 @@ export function redeployTasks(ctx, task) {
447
448
  if (ctx.autheliaWasActive) {
448
449
  subtask.output = 'Starting Authelia...';
449
450
  await execa('systemctl', ['start', 'authelia']);
451
+ // systemctl returns when the process is spawned; the panel, which
452
+ // starts next, talks to Authelia at once.
453
+ if (!(await autheliaReady(execa))) {
454
+ throw new Error(
455
+ 'Authelia is not serving requests after the redeploy — see journalctl -u authelia',
456
+ );
457
+ }
450
458
  }
451
459
  if (ctx.gatekeeperWasActive) {
452
460
  subtask.output = 'Starting gatekeeper...';
@@ -55,14 +55,14 @@ SSH into a fresh Ubuntu droplet and run one command:
55
55
 
56
56
  ```bash
57
57
  apt install -y npm
58
- npx @lamalibre/create-lamaste@4.6.0
58
+ npx @lamalibre/create-lamaste@4.7.0
59
59
  ```
60
60
 
61
61
  This installs everything the relay needs: nginx, Node.js, firewall rules, certificates, and the management panel. At the end, it prints a client certificate file and password. You download the certificate, import it into your browser, and disconnect SSH. You never SSH in again.
62
62
 
63
63
  **Option B: Desktop app (cloud provisioning)**
64
64
 
65
- Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.6.0`) and use the Servers tab to create a DigitalOcean droplet. The app handles everything — droplet creation, Lamaste installation, certificate download, and connection — without you ever touching SSH. Cloud API tokens are stored in your OS credential store (macOS Keychain or Linux libsecret), never in plaintext. See the [Cloud Provisioning guide](../02-guides/cloud-provisioning.md) for a step-by-step walkthrough.
65
+ Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.7.0`) and use the Servers tab to create a DigitalOcean droplet. The app handles everything — droplet creation, Lamaste installation, certificate download, and connection — without you ever touching SSH. Cloud API tokens are stored in your OS credential store (macOS Keychain or Linux libsecret), never in plaintext. See the [Cloud Provisioning guide](../02-guides/cloud-provisioning.md) for a step-by-step walkthrough.
66
66
 
67
67
  **Phase 2: Onboarding (10 minutes, browser)**
68
68
 
@@ -59,7 +59,7 @@ Once connected:
59
59
 
60
60
  ```bash
61
61
  apt install -y npm
62
- npx @lamalibre/create-lamaste@4.6.0
62
+ npx @lamalibre/create-lamaste@4.7.0
63
63
  ```
64
64
 
65
65
  The installer displays a confirmation banner showing what it will do:
@@ -253,7 +253,7 @@ Every tunnel is carried by one **agent** — the machine that runs the app. Enro
253
253
  Install the Lamaste Desktop app, which provides a GUI with automatic service discovery and one-click tunnel creation:
254
254
 
255
255
  ```bash
256
- npx @lamalibre/create-lamaste-desktop@4.6.0
256
+ npx @lamalibre/create-lamaste-desktop@4.7.0
257
257
  ```
258
258
 
259
259
  The app auto-detects local services (Ollama, ComfyUI, PostgreSQL, Docker containers, etc.) and lets you expose them with one click. See the [Desktop App Setup](../02-guides/desktop-app-setup.md) guide for details.
@@ -263,7 +263,7 @@ The app auto-detects local services (Ollama, ComfyUI, PostgreSQL, Docker contain
263
263
  In the panel, go to **Certificates → Enrollment Token** and create a token for a label such as `laptop`. Then, on the machine that runs your app, install the agent globally and run setup:
264
264
 
265
265
  ```bash
266
- npm install -g @lamalibre/lamaste-agent@4.6.0
266
+ npm install -g @lamalibre/lamaste-agent@4.7.0
267
267
  LAMALIBRE_LAMASTE_ENROLLMENT_TOKEN=<token> \
268
268
  lamaste-agent setup --label laptop --panel-url https://203.0.113.42:9292
269
269
  ```
@@ -345,7 +345,7 @@ Your Mac DigitalOcean Droplet Internet
345
345
  The installer accepts optional flags:
346
346
 
347
347
  ```bash
348
- npx @lamalibre/create-lamaste@4.6.0 [flags]
348
+ npx @lamalibre/create-lamaste@4.7.0 [flags]
349
349
  ```
350
350
 
351
351
  | Flag | Effect |
@@ -435,7 +435,7 @@ In development mode (`NODE_ENV=development` or `NODE_ENV` unset), the panel serv
435
435
  # 2. SSH in and install
436
436
  ssh root@203.0.113.42
437
437
  apt install -y npm
438
- npx @lamalibre/create-lamaste@4.6.0
438
+ npx @lamalibre/create-lamaste@4.7.0
439
439
 
440
440
  # 3. Download cert (from your local machine)
441
441
  scp root@203.0.113.42:/etc/lamalibre/lamaste/pki/client.p12 .
@@ -36,7 +36,7 @@ Lamaste solves the problem of exposing local services to the internet securely.
36
36
  **Option A: Manual setup (SSH)**
37
37
 
38
38
  1. Create a fresh Ubuntu 24.04 droplet on DigitalOcean
39
- 2. SSH in once and run `npx @lamalibre/create-lamaste@4.6.0`
39
+ 2. SSH in once and run `npx @lamalibre/create-lamaste@4.7.0`
40
40
  3. Download the client certificate it prints
41
41
  4. Import the certificate into your browser
42
42
  5. Open `https://<droplet-ip>:9292` — you are in the admin panel
@@ -46,7 +46,7 @@ Lamaste solves the problem of exposing local services to the internet securely.
46
46
 
47
47
  **Option B: Desktop app cloud provisioning**
48
48
 
49
- 1. Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.6.0`)
49
+ 1. Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.7.0`)
50
50
  2. Go to the Servers tab and click "Create a new server"
51
51
  3. Enter your DigitalOcean API token — the app validates scopes and stores it securely in your OS credential store
52
52
  4. Pick a region (the app measures latency to each) and a domain name
@@ -93,14 +93,14 @@ All backend services bind `127.0.0.1` only — nginx is the sole public-facing s
93
93
 
94
94
  State is stored in JSON files (`/etc/lamalibre/lamaste/panel.json`, `tunnels.json`) with atomic writes (write to temp → fsync → rename). No database is needed at this scale.
95
95
 
96
- The installer (`npx @lamalibre/create-lamaste@4.6.0`) is completely non-interactive — it detects the environment, provisions everything, and prints a summary. All configuration happens through the browser-based onboarding wizard after installation.
96
+ The installer (`npx @lamalibre/create-lamaste@4.7.0`) is completely non-interactive — it detects the environment, provisions everything, and prints a summary. All configuration happens through the browser-based onboarding wizard after installation.
97
97
 
98
98
  ## Quick Reference
99
99
 
100
100
  | Item | Value |
101
101
  | -------------------- | ------------------------------- |
102
102
  | **Minimum VPS** | 512MB RAM, Ubuntu 24.04 |
103
- | **Install command** | `npx @lamalibre/create-lamaste@4.6.0` |
103
+ | **Install command** | `npx @lamalibre/create-lamaste@4.7.0` |
104
104
  | **Admin panel** | `https://<ip>:9292` (mTLS) |
105
105
  | **Auth for admin** | Client certificate (zero-login) |
106
106
  | **Auth for apps** | TOTP 2FA via Authelia |
@@ -108,5 +108,5 @@ The installer (`npx @lamalibre/create-lamaste@4.6.0`) is completely non-interact
108
108
  | **TLS certificates** | Let's Encrypt (auto-renewing) |
109
109
  | **State storage** | JSON files (no database) |
110
110
  | **RAM usage** | ~245MB total (all services) |
111
- | **npm package** | `@lamalibre/create-lamaste@4.6.0` |
111
+ | **npm package** | `@lamalibre/create-lamaste@4.7.0` |
112
112
  | **License** | Polyform Noncommercial 1.0.0 |
@@ -40,7 +40,7 @@ From the Users page in the management panel, you can:
40
40
  1. Navigate to the Users page in the management panel
41
41
  2. Click "Add User"
42
42
  3. Enter a username, display name, email, and password
43
- 4. Lamaste creates the user in `users.yml` and restarts Authelia
43
+ 4. Lamaste creates the user in `users.yml`; Authelia reloads it in place
44
44
  5. Share the credentials with the user — they enroll in TOTP on first login
45
45
 
46
46
  ### TOTP enrollment flow
@@ -221,7 +221,7 @@ users:
221
221
 
222
222
  Lamaste manages this file through the panel server API. All writes are atomic: content is written to a temporary file first, then moved into place. This prevents Authelia from reading a partially written file.
223
223
 
224
- After every write to `users.yml`, the panel server reloads Authelia via `systemctl restart authelia` so the changes take effect.
224
+ Authelia watches `users.yml` (`authentication_backend.file.watch: true`) and reloads it in place; with the watch it re-reads a user's details at each of their requests, so a change applies at the user's next request without a restart and without ending anyone else's session. It skips a reload within 0.5 s of the previous one, so the panel writes the file once more 0.6 s after its last write.
225
225
 
226
226
  ### Creating users programmatically
227
227
 
@@ -420,6 +420,10 @@ Key choices:
420
420
 
421
421
  See [Registry Credential and OIDC Clients](../04-api-reference/registry-and-oidc.md) for the manifest fields and the API.
422
422
 
423
+ ### Several domains
424
+
425
+ On a relay with several domains Authelia carries one `session.cookies` entry per active domain (each `authelia_url: https://auth.<domain>`), so each domain's sign-in sets and reads its own `authelia_session` cookie — same name, scoped by the browser. `access_control` admits each domain's active members on the domain and every host under it (protected sites' rules narrowed to members), then denies; `default_policy` is `deny`. OIDC sign-ins are gated by per-domain `authorization_policies`, since `access_control` does not apply to them, and each domain is an issuer of its own (`https://auth.<domain>`). The panel renders all of it from the directory's memberships ([Domains API](../04-api-reference/domains.md)); on a one-domain relay every user is the domain's member, which admits whom it did before. TOTP is per person, not per domain.
426
+
423
427
  ### Safety rule: never delete the last user
424
428
 
425
429
  The panel server API prevents deleting the last Authelia user. If only one user exists and you try to delete them, the API returns an error. This prevents a lockout scenario where no one can authenticate to reach the tunneled apps.
@@ -102,6 +102,10 @@ From the Gatekeeper dashboard in the admin panel:
102
102
 
103
103
  Gatekeeper reads groups and grants from its SQLite database (`gatekeeper.db`) on every check, so a group, member or grant created through the admin panel takes effect on the next request — no service restart required. The tunnel list (the panel's `tunnels.json`) and Gatekeeper's settings (`gatekeeper.json`) are watched and reloaded when they change. Changes through the panel also recompute the audiences of agent-hosted plugins at once; edits made with Gatekeeper's own CLI are picked up by the panel within a minute.
104
104
 
105
+ ### Domains and membership
106
+
107
+ On a relay with several domains, membership of a host's domain comes before every access mode: `authenticated` means "any signed-in member of this domain", and `restricted` still needs a grant on top. Membership is rendered into Authelia and checked again by Gatekeeper from the panel's `domains.json`; a signed-in non-member gets a "not a member of this domain" page — with the admin contact and a sign-out link — rather than the sign-in page. Members are managed per domain ([Domains API](../04-api-reference/domains.md)); the admin certificate manages every domain, and Authelia's `admins` group needs membership like everyone else (inside a domain it still passes `restricted` sites).
108
+
105
109
  ## For Developers
106
110
 
107
111
  ### Architecture
@@ -29,7 +29,7 @@ For the IP-based admin panel (`https://<ip>:9292`), there is no domain to verify
29
29
 
30
30
  ### When certificates are issued
31
31
 
32
- - **Self-signed + mTLS certs** — created during `npx @lamalibre/create-lamaste@4.6.0` installation
32
+ - **Self-signed + mTLS certs** — created during `npx @lamalibre/create-lamaste@4.7.0` installation
33
33
  - **Core Let's Encrypt certs** — issued during onboarding for `panel`, `auth`, and `tunnel` subdomains
34
34
  - **App Let's Encrypt certs** — issued when you create a new tunnel or a managed static site
35
35
  - **Custom-domain site certs** — issued when a custom-domain site's DNS is verified: one certificate lineage, named after the site's domain, covering the domain and every redirect alias (re-issued when aliases change)
@@ -82,7 +82,7 @@ The most critical scenario is the mTLS CA expiring, but it has a 10-year validit
82
82
 
83
83
  ### Let's Encrypt implementation
84
84
 
85
- Lamaste uses certbot with the nginx plugin for certificate issuance. The implementation lives in `@lamalibre/lamaste@4.6.0/server` (`packages/core/lib/src/server/certbot.ts`), wrapped by `packages/server/daemon/src/lib/certbot.js`.
85
+ Lamaste uses certbot with the nginx plugin for certificate issuance. The implementation lives in `@lamalibre/lamaste@4.7.0/server` (`packages/core/lib/src/server/certbot.ts`), wrapped by `packages/server/daemon/src/lib/certbot.js`.
86
86
 
87
87
  The panel runs as the unprivileged `lamaste` user and never runs `certbot` or `openssl` on Let's Encrypt files directly. It calls two root-owned wrapper scripts through sudo:
88
88
 
@@ -193,7 +193,7 @@ This three-step check prevents unnecessary certificate issuance and respects rat
193
193
 
194
194
  #### Custom-domain site certificate issuance
195
195
 
196
- A custom-domain static site may have redirect aliases (e.g. `www.myblog.net`). `issueSiteCert(fqdn, aliases, email)` in `@lamalibre/lamaste@4.6.0/server` keeps them on **one** lineage named after the primary domain:
196
+ A custom-domain static site may have redirect aliases (e.g. `www.myblog.net`). `issueSiteCert(fqdn, aliases, email)` in `@lamalibre/lamaste@4.7.0/server` keeps them on **one** lineage named after the primary domain:
197
197
 
198
198
  ```bash
199
199
  sudo /usr/local/sbin/lamaste-certbot issue admin@example.com myblog.net myblog.net www.myblog.net
@@ -260,6 +260,15 @@ Once set during onboarding, the domain is used throughout the system for constru
260
260
  | `packages/lamaste-serverd/src/lib/nginx.js` | Vhost creation with domain-based server_name |
261
261
  | `packages/lamaste-serverd/src/lib/state.js` | Domain storage in panel.json |
262
262
 
263
+ ## Multiple domains
264
+
265
+ One relay can serve several base domains: the **primary** (set at onboarding — `panel.`, `tunnel.` and agent panels live there) and **added** domains, each with its own sign-in at `auth.<domain>` and its own member list. People have one account across all of them; a person reaches a domain's protected hosts only as a member, and signs in once per domain (browsers never share cookies between domains).
266
+
267
+ - **DNS for an added domain:** an A record for `auth.<domain>`, and either a wildcard `*.<domain>` or one A record per hostname, all to the relay. Explicit records (apex, `www`, mail) keep precedence over a wildcard. Steps: [Adding a Second Domain](../02-guides/second-domain.md).
268
+ - **Domains never overlap:** `a.samplepark.tr` cannot be added beside `samplepark.tr` — a domain's cookies reach every host under it.
269
+ - **Hostnames:** a tunnel or managed site is `<label>.<domain>`, unique as a whole; the reserved labels hold on every domain; the domain of a hostname is the one it is under (never stored separately). A domain's apex and `www.` may be a custom site or an alias.
270
+ - **Records** live in the panel's `state.db` (`domains`, `domain_members`); `panel.json` `domain` stays the primary.
271
+
263
272
  ## Quick Reference
264
273
 
265
274
  ### Required DNS records (onboarding)
@@ -25,7 +25,7 @@ You encounter mTLS during two moments:
25
25
 
26
26
  ### Importing the certificate
27
27
 
28
- After running `npx @lamalibre/create-lamaste@4.6.0`, the installer prints something like:
28
+ After running `npx @lamalibre/create-lamaste@4.7.0`, the installer prints something like:
29
29
 
30
30
  ```
31
31
  ╔══════════════════════════════════════════════════════════════╗
@@ -101,7 +101,7 @@ When connecting a machine to Lamaste using `lamaste-agent`, you should **not** u
101
101
  2. Click "Generate" and enter a label (e.g., `macbook-pro`)
102
102
  3. Save the displayed password — it cannot be retrieved later
103
103
  4. Download the `.p12` file
104
- 5. Share the `.p12` file and password with the agent machine through a secure channel, then install the agent there (`npm install -g @lamalibre/lamaste-agent@4.6.0`) and run `lamaste-agent setup` (an enrollment token avoids moving the key at all — see [Agent Setup](../02-guides/agent-setup.md))
104
+ 5. Share the `.p12` file and password with the agent machine through a secure channel, then install the agent there (`npm install -g @lamalibre/lamaste-agent@4.7.0`) and run `lamaste-agent setup` (an enrollment token avoids moving the key at all — see [Agent Setup](../02-guides/agent-setup.md))
105
105
 
106
106
  Agent certificates have capability-based access. By default, a new agent can only read tunnel configuration. Admins can grant additional capabilities per-agent from the panel UI:
107
107
 
@@ -164,7 +164,7 @@ Agents hold only the public key. The daemon fetches it, with the label the relay
164
164
 
165
165
  The owner's token is as good as the owner: the desktop app hands it to curl on stdin, never in a process's arguments, which other local users can read.
166
166
 
167
- Headers that only claim a caller — `X-SSL-Client-*`, `Remote-*` and the assertion itself — are removed from every request before a route or plugin sees it; `Remote-*` are set again only from a verified user assertion, so a plugin reading identity headers (`@lamalibre/lamaste-identity@4.6.0`) reads the relay's word. Whether a request needs a caller follows the route the daemon matched, not the URL: every route does except the health check, the plugin bundles and the SPA's static files, and so does a path no route matches under an installed plugin's name (a plugin may answer it with its own not-found handler). The claim headers are removed from `rawHeaders` too.
167
+ Headers that only claim a caller — `X-SSL-Client-*`, `Remote-*` and the assertion itself — are removed from every request before a route or plugin sees it; `Remote-*` are set again only from a verified user assertion, so a plugin reading identity headers (`@lamalibre/lamaste-identity@4.7.0`) reads the relay's word. Whether a request needs a caller follows the route the daemon matched, not the URL: every route does except the health check, the plugin bundles and the SPA's static files, and so does a path no route matches under an installed plugin's name (a plugin may answer it with its own not-found handler). The claim headers are removed from `rawHeaders` too.
168
168
 
169
169
  **What remains.** The relay's loopback is still the relay's trust boundary: the panel itself trusts the `X-SSL-Client-*` headers nginx sends to `127.0.0.1:3100`, so a process on the relay that can forge those can obtain an assertion as well — what it could do to agents before 4.3. With panel 2FA on it reaches further into agents than into the panel: forged (or stolen) admin-certificate headers no longer pass the panel's own management routes, but the agent-panel authorization is exempt from 2FA (the browser on `agent-<label>.<domain>` carries no 2FA cookie), so they still mint admin assertions — full control of every agent with a panel tunnel. Agent panels are guarded by the client certificate alone, as before; authenticating nginx to the panel is the relay-wide fix. An assertion binds the agent, the port, the method and the target, not the request body: a process on the agent machine that holds the daemon's port while the daemon is down receives the relay's requests, and could replay one with another body if the daemon comes back within the assertion's 65 seconds.
170
170
 
@@ -194,7 +194,7 @@ An agent-hosted plugin's settings may hold secrets — a data provider's API key
194
194
  - **Authenticated answers.** Every answer the agent half gives a verified request carries `X-Lamalibre-Responder`, an HMAC with the secret that verified the request over its nonce, the status and — for Lamaste's own calls — the SHA-256 of the exact answer body. The relay believes nothing without it: a settings call or a hook call fails as `unauthenticated`, the health probe counts the plugin's application as down, and the forwarder answers `502` before a status, header or byte of the answer reaches the client. Every `/admin/*` response of an agent half must carry it; core's handlers, `createHealthHandler` and `responderHeaderFor` produce it.
195
195
  - **Sealed secrets.** Each secret of a settings change crosses the tunnel encrypted (AES-256-GCM, key derived with HKDF-SHA256 from the installation secret per plugin) and bound to the plugin, the call's nonce, the field and the revision; the agent half opens it with the secret that verified the call.
196
196
 
197
- Agent halves built against `@lamalibre/lamaste@4.6.0` 4.3 must be rebuilt: one that does not sign its answers is down to the relay.
197
+ Agent halves built against `@lamalibre/lamaste@4.7.0` 4.3 must be rebuilt: one that does not sign its answers is down to the relay.
198
198
 
199
199
  **What a port's squatter still receives.** These mechanisms authenticate answers and seal secrets; they do not keep the relay from _sending_. A squatter cannot pass a single health probe (it holds no installation secret), so the health monitor marks the hosting down after three failed probes in a row, 10 s apart. From then on **neither forwarder sends anything to the port**: a user's request is answered `503` by the panel, and an administrator's or agent's request on the admin API is answered as a request into a dead tunnel is — the plugin's `tunnel:down` fallback, or `502` with the reason (`tunnel` or `app`) — without a connection being made. So a forwarded request — its method, path, query and body, and the caller assertion with an administrator, an agent, or a user's `userId`, username and roles — can reach a relay-loopback squatter only in the window before the hosting is marked down, about 30 seconds after the agent went away (and, when the agent comes back, a squatter that still holds the port is what the probes find: the hosting stays down). Lamaste's own calls do not go through the forwarders and keep reaching the port — the health probe has to, to see the agent half come back: their bodies are bound to their assertions and a settings change's secrets are sealed, so a squatter learns their non-secret content and nothing it can replay.
200
200
 
@@ -242,7 +242,7 @@ Gatekeeper attests the session; the panel issues the identity. Gatekeeper never
242
242
 
243
243
  **Data minimisation.** A plugin always receives a user's `userId` and **username** — in the caller assertion of every request and in every member and last-known record of its feed — and of the other attributes only those its manifest declares (`displayName`, `email`); widening them waits for an administrator's approval that auto-approve never gives (`identity.changed`). Nothing identity is ever sent to a plugin that does not declare `identity`. A user who leaves keeps, on the plugin's side, only what it last received. The feed's batches travel **sealed**: their changes are encrypted (AES-256-GCM) with a key derived from the hosting's installation secret — domain-separated from the settings secrets' — and bound to the caller assertion's nonce and the batch envelope, so the chisel server and whatever holds a loopback end of the internal tunnel never see a member in clear. Audit entries name users by `userId` alone, and the feed's wire parsers never echo a received value. The panel and Gatekeeper log one metadata line per request — method, **route pattern** (never the URL or its query), status, duration, certificate role, and the client address only for certificate holders, never for a person — instead of Fastify's own request lines; the panel's log lines name users by `userId`; Gatekeeper's principal removal and access-log purge take the username in a request body, never a URL. `users.yml`'s content never reaches a log, even when it cannot be parsed. What nginx's access log, journald and Authelia's log keep, and for how long: [Monitoring](../05-operations/monitoring.md#logs-and-personal-data).
244
244
 
245
- **Offboarding and erasure.** One offboarding function, keyed by `userId`, runs for every user who stops being one — disabled or deleted through the users API, or disabled or removed in `users.yml` outside it (adopted by the minute's reconcile): their desktop sessions end (an epoch keyed by `userId`; sessions are bound to the `userId`, so a username given to someone else revives none), their registry tokens are revoked, Gatekeeper drops its cached session verdicts, and for a removal their legacy plugin-access grants, Gatekeeper principal (every grant and group membership under the username) and Authelia second factors (TOTP configuration, WebAuthn credentials) go. Disabling is effective at once: the directory is written before Authelia restarts, so the user surface refuses them (`403`) before Authelia lets them out, and every audience shows them disabled; a disable that Authelia's own rewrite of `users.yml` undoes is made again, and reported when it does not stick. Deleting keeps a tombstone (the `userId` and the last known name and address, `removed`) so history stays readable. **A re-created username is a new user with a new `userId` and inherits no grant, group, role, second factor or session**: at every creation over a tombstone Gatekeeper drops everything under the username and Authelia its second factors (refused while either cannot), a username a deleted user held and nobody holds takes no grant or membership (`409 username_deleted`), and grants are given under the users lock so none races a deletion. The one exception Lamaste cannot remove is the **OpenID Connect identity**: Authelia's opaque identifier (`sub`) for a username cannot be deleted, so a username Authelia holds one for is never given to a new user (`409 username_has_oidc_identity`). **Erasing** scrubs the tombstone, the user's invitations, Gatekeeper's access-request log rows and Authelia's second factors, and rewrites the user's row to `remove/erased` in every plugin that ever had them — uninstalled plugins whose data an agent keeps and plugins that no longer declare identity included (a plugin's audience is its erasure ledger, dropped only when no agent keeps its data); its agent half scrubs its row and anonymises its own data in one transaction, never takes the user back afterwards (erasure is sticky in its projection), and the panel shows the erasure as pending until each plugin's data has it. Outside Lamaste's reach: Authelia's opaque identifier and authentication log, nginx's access logs, journald's older entries, and backups taken before ([Backup and Restore](../05-operations/backup-and-restore.md) on restoring a relay).
245
+ **Offboarding and erasure.** One offboarding function, keyed by `userId`, runs for every user who stops being one — disabled or deleted through the users API, or disabled or removed in `users.yml` outside it (adopted by the minute's reconcile): their desktop sessions end (an epoch keyed by `userId`; sessions are bound to the `userId`, so a username given to someone else revives none), their registry tokens are revoked, Gatekeeper drops its cached session verdicts, and for a removal their legacy plugin-access grants, Gatekeeper principal (every grant and group membership under the username) and Authelia second factors (TOTP configuration, WebAuthn credentials) go. Disabling is effective at once: the directory is written in the same step, so the user surface refuses them (`403`), Authelia ends their session at their next request, and every audience shows them disabled; a disable that Authelia's own rewrite of `users.yml` undoes is made again, and reported when it does not stick. Deleting keeps a tombstone (the `userId` and the last known name and address, `removed`) so history stays readable. **A re-created username is a new user with a new `userId` and inherits no grant, group, role, second factor or session**: at every creation over a tombstone Gatekeeper drops everything under the username and Authelia its second factors (refused while either cannot), a username a deleted user held and nobody holds takes no grant or membership (`409 username_deleted`), and grants are given under the users lock so none races a deletion. The one exception Lamaste cannot remove is the **OpenID Connect identity**: Authelia's opaque identifier (`sub`) for a username cannot be deleted, so a username Authelia holds one for is never given to a new user (`409 username_has_oidc_identity`). **Erasing** scrubs the tombstone, the user's invitations, Gatekeeper's access-request log rows and Authelia's second factors, and rewrites the user's row to `remove/erased` in every plugin that ever had them — uninstalled plugins whose data an agent keeps and plugins that no longer declare identity included (a plugin's audience is its erasure ledger, dropped only when no agent keeps its data); its agent half scrubs its row and anonymises its own data in one transaction, never takes the user back afterwards (erasure is sticky in its projection), and the panel shows the erasure as pending until each plugin's data has it. Outside Lamaste's reach: Authelia's opaque identifier and authentication log, nginx's access logs, journald's older entries, and backups taken before ([Backup and Restore](../05-operations/backup-and-restore.md) on restoring a relay).
246
246
 
247
247
  **What remains.** Plain `authenticated` and `restricted` app tunnels still pass the user to their backend as **unsigned** `Remote-User`, `Remote-Groups`, `Remote-Name` and `Remote-Email` headers set from Gatekeeper's answer: nginx replaces whatever the client sent, but the backend's port is reachable on the relay's and the agent's loopback by any local process, which can send any `Remote-*` it likes. Only plugin tunnels (a relay assertion for lamaste-agentd, 5c) and plugin user surfaces carry a signed identity; Gatekeeper-signed identity for plain tunnels is future work. A backend never receives the session cookie (5a), but it answers on a host under the relay's domain and can set cookies there — a second `authelia_session` with another path, which Gatekeeper refuses rather than guess. Until the hosting is marked down, a forwarded request — a user's or an administrator's — can reach a relay-loopback squatter (5e). The relay's loopback stays the relay's trust boundary: a process on the relay that can read the relay signing key can mint assertions for the panel, as it could for agents before.
248
248
 
@@ -664,6 +664,31 @@ Configuration files that are read live by services (like Authelia's `users.yml`)
664
664
  | `packages/core/lib/src/agent/plugin-settings-store.ts` | The agent half's settings files and `/admin/settings` |
665
665
  | `packages/server/daemon/src/lib/plugin-settings.js` | Settings through the tunnel; the cache without secrets |
666
666
 
667
+ ## Several domains on one relay
668
+
669
+ A relay may serve several base domains with one user directory and an explicit member list per domain ([Domains API](../04-api-reference/domains.md)).
670
+
671
+ - **Membership is enforced twice, from one source** (the panel's `state.db`, and Gatekeeper's grants for external members): Authelia (groups Lamaste owns in `users.yml`, named by `access_control` rules with deny last and `default_policy: deny`, and by one OIDC authorization policy per user-facing client) and Gatekeeper (the `domains.json` projection, fail closed; read again on every cache bust and every minute, whatever its file watcher saw). Either alone keeps a non-member out of tunnels and plugin surfaces; static sites rely on Authelia, OIDC sign-ins on the clients' authorization policies. One lock (`withDomainRendering`) serializes everything that writes what members may do. Both are always rendered — one failing never keeps the other at older members — and a failing rendering is audited (`domain.render.failed`) and raised to the administrators until it succeeds.
672
+ - **A protected site is never served open:** a protected static site whose hostname is under none of the relay's domains (possible since 4.6) answers `403` to everyone, and editing or re-verifying it is refused until it is unprotected or its domain is added.
673
+ - **A user-facing OIDC client is always gated by membership:** a client is either a service (`client_credentials` alone) or user-facing, never both — Authelia gives a `client_credentials` client `one_factor`, never a domain's member policy.
674
+ - **Cookies never cross domains:** domains may not overlap **and may not share a registrable domain** (eTLD+1 by the Public Suffix List; a public suffix is never a domain) — otherwise an application on `cust1.example.net` could set an `authelia_session` on `example.net` that the browser sends to `cust2.example.net` (a planted session, a forced sign-in, a lock-out) and both would be one site for `SameSite`. So one domain's session cookie never reaches another's hosts; the backend cookie map strips `authelia_session` on every domain. Each `auth.<domain>` passes Authelia its own name as `X-Forwarded-Host` / `-Proto` — a client cannot make one domain's portal act as another's issuer or cookie domain.
675
+ - **Nothing lands on a domain as it changes:** adding, activating and removing a domain hold the tunnel and sites locks under which tunnels, sites and plugin moves resolve their domain and sites take hostnames; the registry credential is written under the domains lock. The relay's own hostnames (`auth.`, `panel.`, `tunnel.`, `agent-*`) of a pending domain are kept free, and a domain is added and activated only while no tunnel, site or alias holds one. An invitation grants, at acceptance, only the domains it names that existed when it was made — a domain removed and added again is not one of them.
676
+ - **Registry tokens follow the registry's sign-in**: a user whom none of the registry's own sign-in clients (the user-facing clients whose audience is the registry) admits any more — whatever narrowed them, a revocation with Gatekeeper's own CLI included — loses their Feria tokens (best effort; they sign in again); none such client, or one that cannot be read, revokes (fail closed). Changing the credential's domains revokes those of everyone outside the new ones. Every narrowing is audited (`directory.access.narrowed`, by id).
677
+ - **One issuer per domain:** a token carries the issuer it was obtained at; a relying party trusting one issuer refuses the others.
678
+ - **External members reach only what names them** — `restricted` tunnels and surfaces that grant them, static sites that list them, OIDC clients granted to them — checked by Authelia (an external member's rule admits them only on a host whose name is one of their groups, and the panel gives them a host's group only for a grant on it; an `authenticated` host has none, so it is closed to them from its first request) and again by Gatekeeper (the projection carries the kind). They hold no Authelia group (`admins`, `feria-*`), so no group grants them anything by membership; Lamaste (Gatekeeper) groups in grants apply to them. Making a member external takes effect at their next request.
679
+ - **Who is let in changes without a restart.** Memberships, kinds, grants, users and sites' listed users are groups Lamaste owns in `users.yml`, which Authelia watches and re-reads at each request (`refresh_interval: always`, rendered); if its configuration could not be brought to that, a change of the file restarts it. The panel touches the file 0.6 s after its last write — read again, Lamaste's groups applied again, written — because Authelia skips a reload within 0.5 s of the previous one; the start and the identity tick touch it too. Authelia's own password change and reset, which would write the file from an older copy, are disabled. A write never drops a user it was not told to delete, never gives a user the rendering did not know (a reused username) any of Lamaste's groups, and only narrows after a rendering failed; when the grants cannot be read, external members hold no granted host or client until they can (fail closed). The names of those groups are reserved: an administrator assigns only `[A-Za-z0-9_-]` names, and the panel removes any owned-looking group it did not render — compared folded as Authelia compares (`ſ` is `s`), under every domain and every removed one.
680
+ - **Grants are access, kept until revoked**: Gatekeeper consumes only a local plugin grant (its desktop enrolment), and sweeps only those; tunnel, site, agent-plugin and OIDC-client grants were swept 90 days after creation before 4.7. Uninstalling a plugin revokes the grants on its OIDC clients.
681
+ - **The accepted shared blast radius:** one droplet, nginx, panel, Gatekeeper and Authelia serve every domain, and the admin certificate manages all of them. A compromise of the relay reaches every domain it serves — the normal hosting trade-off, documented rather than engineered around. A customer who must keep its users off the relay needs a relay of its own (or, later, an external identity provider for its domain).
682
+ - **Same-site:** applications on one domain share its registrable domain as before; a customer's applications on their own domain are now a separate site from Code Lama's.
683
+
684
+ **What remains.**
685
+
686
+ - **OIDC access tokens already issued.** Authelia checks a client's authorization policy — membership of its domain, or an external member's grant — when a user authorizes it, not afterwards. Plugin clients therefore get no refresh tokens (the manifest refuses the `refresh_token` grant and the `offline` / `offline_access` scopes; a plugin installed before is served without them), and the access and ID tokens of user-facing clients live 15 minutes: a user removed from a domain, or whose grant was revoked, keeps at most those, until they expire. Feria's own tokens are revoked (above).
687
+ - **One session store.** Authelia keeps every domain's sessions in one store; the separation between domains is the browser's cookie scoping plus membership by username — a session value moved to another domain's cookie would be accepted there for a member. The backend cookie map keeps session values away from every backend.
688
+ - **Structural changes restart Authelia** — a domain added, activated or removed, a static site protected, unprotected, created or removed, the set of OIDC clients or a plugin's domain changed — which ends every session on every domain (Authelia keeps sessions in memory): an availability cost, not an access one, and an administrator's action (R3, narrowed in 4.7).
689
+ - **Authelia's device-code flow reads a session's groups as they were** (R5): unlike the authorization-code flow, it checks a client's policy against the groups the session last refreshed. So a change that takes a device-code client (`feria login`) away from someone — a membership ended or made external on a domain serving one, a grant on one revoked — restarts Authelia, ending every session on every domain. External members without such a grant (corral's) never cause it. To be reported upstream.
690
+ - **Lamaste's groups reach an OIDC client that asks for `groups`** (R4): Authelia's standard `groups` claim cannot be filtered, so such a client — one the administrator installed, Feria's `feria login` — sees the user's groups. They are opaque — a random key per domain (`lamaste:m:<key>`, `lamaste:x:<key>`), a site's id — so no domain or site is named; what remains is that two users sharing a key share a domain, the client ids granted to an external member, and an external member's granted hostnames on their own domain. Gatekeeper keeps them from every tunnel backend.
691
+
667
692
  ## Quick Reference
668
693
 
669
694
  ### Security layers