@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
@@ -37,15 +37,27 @@
37
37
  * tunnel or agent backend keeps Authelia's session cookie from it
38
38
  * (`$lamaste_backend_cookie`), so an installed vhost from before that is
39
39
  * rewritten too.
40
+ *
41
+ * From the multi-domain relay, tunnel vhosts are named by their full
42
+ * hostname and every domain has its own auth portal
43
+ * (`lamalibre-lamaste-auth-<domain>`): {@link renameLegacyVhosts} renames
44
+ * the ones an older version named by subdomain, in one reload, before the
45
+ * reconcile renders them.
40
46
  */
41
47
 
42
- import { tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
43
- import { withTunnelLock } from '@lamalibre/lamaste/server';
48
+ import { domainOfHost, tunnelUserSurfaceRoutes } from '@lamalibre/lamaste';
49
+ import { tunnelHost, vhostTunnelHost, withTunnelLock } from '@lamalibre/lamaste/server';
44
50
  import {
51
+ LEGACY_AUTH_VHOST,
45
52
  PANEL_DOMAIN_VHOST,
53
+ agentPanelVhostName,
54
+ appVhostName,
55
+ authVhostName,
46
56
  installVhost,
47
57
  isSiteEnabled,
58
+ legacyTunnelVhostName,
48
59
  readInstalledVhost,
60
+ renameVhosts,
49
61
  renderAgentPanelVhost,
50
62
  renderAuthVhost,
51
63
  renderPanelVhost,
@@ -53,13 +65,12 @@ import {
53
65
  renderTunnelVhost,
54
66
  vhostCertDir,
55
67
  } from './nginx.js';
68
+ import { notifyAdminsBestEffort } from './notifications.js';
56
69
  import { readSites, readTunnels } from './state.js';
57
70
 
58
71
  /** A subdomain or site id as it may appear in a site name. */
59
72
  const SITE_NAME_PART_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/;
60
73
 
61
- const AUTH_VHOST = 'lamalibre-lamaste-auth';
62
-
63
74
  /**
64
75
  * Which of a tunnel's bound routes its vhost serves now — the panel's
65
76
  * plugins and their hostings decide (`lib/user-surface.js`), loaded when
@@ -84,11 +95,10 @@ async function servedUserSurface(routes, tunnel) {
84
95
  * @returns {{ name: string, fqdn: string, config: string } | null}
85
96
  */
86
97
  export function expectedTunnelVhost(tunnel, certDir, userSurface = []) {
87
- if (!managedTunnelVhostName(tunnel) || typeof tunnel.fqdn !== 'string') return null;
88
- if (!tunnel.fqdn.startsWith(`${tunnel.subdomain}.`)) return null;
89
- const domain = tunnel.fqdn.slice(tunnel.subdomain.length + 1);
98
+ if (!managedTunnelVhostName(tunnel)) return null;
99
+ const { fqdn, domain } = tunnelHost(tunnel);
90
100
  if (tunnel.type === 'panel') {
91
- return renderAgentPanelVhost(tunnel.subdomain, domain, tunnel.port, certDir);
101
+ return renderAgentPanelVhost(fqdn, domain, tunnel.port, certDir);
92
102
  }
93
103
  // As createTunnel and updateTunnel write it: the stored access mode
94
104
  // (restricted when absent, as Gatekeeper reads it), the plugin route of a
@@ -97,21 +107,27 @@ export function expectedTunnelVhost(tunnel, certDir, userSurface = []) {
97
107
  // rendered, and a bound tunnel cannot be made public).
98
108
  const accessMode = tunnel.accessMode ?? 'restricted';
99
109
  if (accessMode === 'public' && userSurface.length > 0) return null;
100
- return renderTunnelVhost(accessMode, tunnel.subdomain, domain, tunnel.port, certDir, {
110
+ return renderTunnelVhost(accessMode, fqdn, domain, tunnel.port, certDir, {
101
111
  ...(tunnel.type === 'plugin' && tunnel.pluginRoute ? { pathPrefix: tunnel.pluginRoute } : {}),
102
112
  ...(tunnel.maxBodySizeMb !== undefined ? { maxBodySizeMb: tunnel.maxBodySizeMb } : {}),
103
113
  ...(userSurface.length > 0 ? { userSurface } : {}),
104
114
  });
105
115
  }
106
116
 
107
- /** Site name of a tunnel's vhost, or null for a tunnel without one. */
117
+ /**
118
+ * Site name of a tunnel's vhost — by its hostname — or null for a tunnel
119
+ * without one (internal, or whose state names no `<subdomain>.<domain>`).
120
+ */
108
121
  function managedTunnelVhostName(tunnel) {
109
- if (tunnel.exposure === 'internal' || typeof tunnel.subdomain !== 'string') return null;
110
- if (!SITE_NAME_PART_RE.test(tunnel.subdomain)) return null;
111
- if (tunnel.type === 'panel') return `lamalibre-lamaste-agent-panel-${tunnel.subdomain}`;
122
+ if (typeof tunnel.subdomain !== 'string' || !SITE_NAME_PART_RE.test(tunnel.subdomain)) {
123
+ return null;
124
+ }
125
+ const host = vhostTunnelHost(tunnel);
126
+ if (host === null) return null;
127
+ if (tunnel.type === 'panel') return agentPanelVhostName(host.fqdn);
112
128
  // Tunnels persisted before `type` existed are app tunnels.
113
129
  if (tunnel.type === 'app' || tunnel.type === 'plugin' || tunnel.type === undefined) {
114
- return `lamalibre-lamaste-app-${tunnel.subdomain}`;
130
+ return appVhostName(host.fqdn);
115
131
  }
116
132
  return null;
117
133
  }
@@ -295,43 +311,58 @@ export async function reconcileUserSurfaceVhosts(
295
311
  });
296
312
  }
297
313
 
298
- const defaultSiteDeps = { readSites, readInstalledVhost, installVhost, isSiteEnabled };
314
+ const defaultSiteDeps = {
315
+ readSites,
316
+ readInstalledVhost,
317
+ installVhost,
318
+ isSiteEnabled,
319
+ notify: notifyAdminsBestEffort,
320
+ };
299
321
 
300
322
  /**
301
323
  * Reconcile the vhosts that proxy a browser to the panel or ask Authelia
302
- * directly: the panel's own (`panel.<domain>`), the auth portal
303
- * (`auth.<domain>`) and every static site but a plugin's web surface. Call
304
- * it before the panel listens — then nothing can change a site meanwhile.
324
+ * directly: the panel's own (`panel.<primary>`), every active domain's auth
325
+ * portal (`auth.<domain>`) and every static site but a plugin's web surface.
326
+ * Call it before the panel listens — then nothing can change a site
327
+ * meanwhile.
305
328
  *
306
329
  * @param {Parameters<typeof logResult>[0]} log
307
- * @param {string} domain
330
+ * @param {ReadonlyArray<{ name: string, primary: boolean }>} domains the active domains
308
331
  * @param {typeof defaultSiteDeps} [deps]
309
332
  * @returns {Promise<ReturnType<typeof newResult>>}
310
333
  */
311
- export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
334
+ export async function reconcileSiteVhosts(log, domains, deps = defaultSiteDeps) {
312
335
  const result = newResult();
336
+ const primary = domains.find((d) => d.primary)?.name;
313
337
 
314
- await reconcileOne(
315
- {
316
- name: PANEL_DOMAIN_VHOST,
317
- label: `panel.${domain}`,
318
- render: (certDir) => renderPanelVhost(domain, certDir),
319
- enabled: () => deps.isSiteEnabled(PANEL_DOMAIN_VHOST),
320
- },
321
- result,
322
- deps,
323
- );
338
+ if (primary) {
339
+ await reconcileOne(
340
+ {
341
+ name: PANEL_DOMAIN_VHOST,
342
+ label: `panel.${primary}`,
343
+ render: (certDir) => renderPanelVhost(primary, certDir),
344
+ enabled: () => deps.isSiteEnabled(PANEL_DOMAIN_VHOST),
345
+ },
346
+ result,
347
+ deps,
348
+ );
349
+ }
324
350
 
325
- await reconcileOne(
326
- {
327
- name: AUTH_VHOST,
328
- label: `auth.${domain}`,
329
- render: (certDir) => renderAuthVhost(domain, certDir),
330
- enabled: () => deps.isSiteEnabled(AUTH_VHOST),
331
- },
332
- result,
333
- deps,
334
- );
351
+ for (const domain of domains) {
352
+ const name = authVhostName(domain.name);
353
+ await reconcileOne(
354
+ {
355
+ name,
356
+ label: `auth.${domain.name}`,
357
+ render: (certDir) => renderAuthVhost(domain.name, certDir, { primary: domain.primary }),
358
+ enabled: () => deps.isSiteEnabled(name),
359
+ },
360
+ result,
361
+ deps,
362
+ );
363
+ }
364
+
365
+ const names = domains.map((d) => d.name);
335
366
 
336
367
  let sites = [];
337
368
  try {
@@ -339,17 +370,29 @@ export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
339
370
  } catch (err) {
340
371
  result.failed.push({ vhost: 'sites', error: err instanceof Error ? err.message : String(err) });
341
372
  }
373
+ const closed = [];
342
374
  for (const site of sites) {
343
375
  if (typeof site.id !== 'string' || !SITE_NAME_PART_RE.test(site.id)) continue;
344
376
  // A plugin's web surface is its plugin's to render (lib/web-surface.js
345
377
  // `reconcileWebSurfaces`, once plugins and hostings can be read).
346
378
  if (site.owner) continue;
379
+
347
380
  const name = `lamalibre-lamaste-site-${site.id}`;
348
381
  await reconcileOne(
349
382
  {
350
383
  name,
351
384
  label: site.fqdn ?? site.id,
352
- render: (certDir) => renderStaticSiteVhost(site, certDir, domain),
385
+ render: (certDir) => {
386
+ const rendered = renderStaticSiteVhost(
387
+ site,
388
+ certDir,
389
+ typeof site.fqdn === 'string' ? domainOfHost(site.fqdn, names) : null,
390
+ );
391
+ // Protected, but under none of the relay's domains (possible since
392
+ // 4.6): it renders closed — said once, at the end.
393
+ if (rendered.closed) closed.push(site.fqdn);
394
+ return rendered;
395
+ },
353
396
  enabled: () => deps.isSiteEnabled(name),
354
397
  // A site still waiting for its DNS or certificate has no vhost yet.
355
398
  reportMissing: site.certIssued === true,
@@ -359,6 +402,71 @@ export async function reconcileSiteVhosts(log, domain, deps = defaultSiteDeps) {
359
402
  );
360
403
  }
361
404
 
405
+ if (closed.length > 0) {
406
+ log.warn(
407
+ { sites: closed },
408
+ 'protected static sites under none of the relay domains are closed (403)',
409
+ );
410
+ await deps.notify(
411
+ {
412
+ source: 'lamaste',
413
+ level: 'warning',
414
+ message: `Protected static sites under none of this relay's domains answer 403 to everyone: ${closed.join(', ')}. Add their domain, or turn protection off.`,
415
+ },
416
+ log,
417
+ );
418
+ }
419
+ result.closed = closed;
420
+
362
421
  logResult(log, 'site', result);
363
422
  return result;
364
423
  }
424
+
425
+ const defaultRenameDeps = { readTunnels, renameVhosts };
426
+
427
+ /**
428
+ * Rename the vhosts an older version named by subdomain — every tunnel's
429
+ * (`lamalibre-lamaste-app-<subdomain>`, `-agent-panel-<subdomain>`) and the
430
+ * auth portal (`lamalibre-lamaste-auth`, the primary's) — to their names by
431
+ * hostname, in one reload ({@link renameVhosts} in nginx.js: same text, same
432
+ * link state; all of it undone when nginx refuses). Idempotent: a vhost
433
+ * already renamed, or never installed, is left alone. Before the panel
434
+ * listens, under the tunnel lock; a failure is logged and the old names keep
435
+ * serving (the next start tries again).
436
+ *
437
+ * @param {Parameters<typeof logResult>[0]} log
438
+ * @param {string} primary the primary domain
439
+ * @param {typeof defaultRenameDeps} [deps]
440
+ * @returns {Promise<string[]>} the names written
441
+ */
442
+ export async function renameLegacyVhosts(log, primary, deps = defaultRenameDeps) {
443
+ return withTunnelLock(async () => {
444
+ const renames = [
445
+ { from: LEGACY_AUTH_VHOST, to: authVhostName(primary), serverName: `auth.${primary}` },
446
+ ];
447
+ for (const tunnel of await deps.readTunnels()) {
448
+ const to = managedTunnelVhostName(tunnel);
449
+ if (to === null) continue;
450
+ // Names by subdomain existed only on the primary domain, and an old
451
+ // file is the tunnel's only when it serves the tunnel's hostname — a
452
+ // same-subdomain tunnel of another domain never takes it.
453
+ if (tunnel.fqdn !== `${tunnel.subdomain}.${primary}`) continue;
454
+ renames.push({
455
+ from: legacyTunnelVhostName(tunnel.type, tunnel.subdomain),
456
+ to,
457
+ serverName: tunnel.fqdn,
458
+ });
459
+ }
460
+ try {
461
+ const renamed = await deps.renameVhosts(renames);
462
+ if (renamed.length > 0) log.info({ renamed }, 'Renamed vhosts to their hostnames');
463
+ return renamed;
464
+ } catch (err) {
465
+ log.error(
466
+ { err: err instanceof Error ? err.message : String(err) },
467
+ 'Renaming vhosts to their hostnames failed; the old names keep serving',
468
+ );
469
+ return [];
470
+ }
471
+ });
472
+ }
@@ -41,10 +41,12 @@ import {
41
41
  import {
42
42
  TUNNEL_MAX_BODY_MB,
43
43
  WEB_TREES_ROOT,
44
+ hostnameHolder,
44
45
  readWebTreeStore,
45
46
  removeWebTreeStore,
46
47
  stageWebTree,
47
48
  switchWebTree,
49
+ tunnelHost,
48
50
  webTreeStoreDir,
49
51
  withTunnelLock,
50
52
  } from '@lamalibre/lamaste/server';
@@ -53,12 +55,14 @@ import { recordAuditBestEffort } from './audit.js';
53
55
  import { getCertPath, issueTunnelCert } from './certbot.js';
54
56
  import { syncChisel } from './chisel.js';
55
57
  import { getConfig } from './config.js';
58
+ import { domainOfHostname, pluginDomainName } from './domains.js';
56
59
  import {
57
60
  createGatekeeperGrant,
58
61
  listResourceGrants,
59
62
  revokeGatekeeperGrant,
60
63
  } from './gatekeeper-client.js';
61
64
  import {
65
+ appVhostName,
62
66
  installVhost,
63
67
  moveAppVhostToWebSurface,
64
68
  readInstalledVhost,
@@ -208,15 +212,25 @@ export async function webSurfaceServing(route, tunnels) {
208
212
  }
209
213
 
210
214
  /**
211
- * The vhost of the site `site` (owned by plugin `route`) as it should be
212
- * now, on certificate directory `certDir`.
215
+ * The active domain a plugin's site is under — always one: it is created
216
+ * on its plugin's domain, and a domain a site uses cannot be removed.
213
217
  */
214
- async function renderOwnedSite(site, route, certDir, tunnels) {
218
+ async function siteDomain(site) {
219
+ const domain = await domainOfHostname(site.fqdn);
220
+ if (domain === null) throw new Error(`${site.fqdn} is under none of the relay's active domains`);
221
+ return domain;
222
+ }
223
+
224
+ /**
225
+ * The vhost of the site `site` (owned by plugin `route`, under `domain`)
226
+ * as it should be now, on certificate directory `certDir`.
227
+ */
228
+ async function renderOwnedSite(site, route, certDir, tunnels, domain) {
215
229
  const serving = await webSurfaceServing(route, tunnels);
216
230
  const maxBodySizeMb = serving.plugin
217
231
  ? await internalTunnelBodyLimit(serving.plugin, tunnels)
218
232
  : undefined;
219
- return renderWebSurfaceVhost(site, certDir, getConfig().domain, {
233
+ return renderWebSurfaceVhost(site, certDir, domain, {
220
234
  route,
221
235
  served: serving.served,
222
236
  maxBodySizeMb: maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default,
@@ -234,12 +248,12 @@ async function reconcileOwnedSiteVhost(site, route, tunnels) {
234
248
  // A move stopped between its state writes and the handover leaves the
235
249
  // tunnel's vhost linked beside the site's, on the same hostname.
236
250
  const moved = tunnels.find((t) => t.fqdn === site.fqdn && t.enabled === false && t.subdomain);
237
- const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.subdomain) : false;
251
+ const unlinked = moved ? await deps.unlinkMovedAppVhost(moved.fqdn) : false;
238
252
  const installed = await deps.readInstalledVhost(siteVhostName(site));
253
+ const domain = await siteDomain(site);
239
254
  const certDir =
240
- (installed && vhostCertDir(installed)) ||
241
- (await deps.getCertPath(site.fqdn, getConfig().domain));
242
- const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels);
255
+ (installed && vhostCertDir(installed)) || (await deps.getCertPath(site.fqdn, domain));
256
+ const { name, fqdn, config } = await renderOwnedSite(site, route, certDir, tunnels, domain);
243
257
  if (installed === config) {
244
258
  if (unlinked) await deps.reloadNginx();
245
259
  return unlinked;
@@ -328,10 +342,12 @@ export async function webSurfaceStatus(route) {
328
342
  let offer = null;
329
343
  if (!site && declared && !boundTunnel) {
330
344
  const subdomain = declared.defaultSubdomain;
345
+ const domain = await pluginDomainName(plugin);
331
346
  offer = {
332
347
  subdomain,
348
+ domain,
333
349
  accessMode: declared.accessMode,
334
- available: (await hostnameTaken(subdomain, sites, tunnels)) === null,
350
+ available: (await hostnameTaken(subdomain, domain, sites, tunnels)) === null,
335
351
  };
336
352
  }
337
353
  return {
@@ -369,21 +385,18 @@ export async function webSurfaceStatus(route) {
369
385
  // ---------------------------------------------------------------------------
370
386
 
371
387
  /**
372
- * Why `subdomain` (of the relay's domain) cannot be a new web surface's
373
- * hostname, or null when it can: reserved, held by a tunnel, or taken by a
374
- * site (its name, hostname or an alias).
388
+ * Why `subdomain` of `domain` cannot be a new web surface's hostname, or
389
+ * null when it can: reserved, held by a tunnel, or taken by a site (its
390
+ * hostname or an alias — or its name: site names are unique relay-wide).
375
391
  */
376
- async function hostnameTaken(subdomain, sites, tunnels) {
392
+ async function hostnameTaken(subdomain, domain, sites, tunnels) {
377
393
  if (!isWebSurfaceSubdomain(subdomain))
378
394
  return `'${subdomain}' cannot be a web surface's subdomain`;
379
- const fqdn = `${subdomain}.${getConfig().domain}`;
380
- if (tunnels.some((t) => t.subdomain === subdomain || t.fqdn === fqdn)) {
381
- return `a tunnel holds '${subdomain}'`;
382
- }
383
- if (
384
- sites.some((s) => s.name === subdomain || s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn))
385
- ) {
386
- return `a site holds '${subdomain}'`;
395
+ const fqdn = `${subdomain}.${domain}`;
396
+ const holder = hostnameHolder(fqdn, { tunnels, sites });
397
+ if (holder) return `${holder.kind === 'tunnel' ? 'a tunnel' : 'a site'} holds '${fqdn}'`;
398
+ if (sites.some((s) => s.name === subdomain)) {
399
+ return `a site is named '${subdomain}'`;
387
400
  }
388
401
  return null;
389
402
  }
@@ -446,7 +459,7 @@ async function serveApprovedTree(plugin, logger) {
446
459
  }
447
460
 
448
461
  /**
449
- * Give plugin `route` its web surface on `subdomain` of the relay's domain
462
+ * Give plugin `route` its web surface on `subdomain` of the plugin's domain
450
463
  * (default: the one its manifest declares) with `accessMode` (default: the
451
464
  * declared one): a certificate, the site entry, the vhost. The plugin must
452
465
  * declare a web surface and own no site, and no tunnel may be bound as its
@@ -479,16 +492,23 @@ export function createWebSurface(route, input, { actor, logger }) {
479
492
  'bound_to_tunnel',
480
493
  );
481
494
  }
482
- const taken = await hostnameTaken(subdomain, sites, tunnels);
495
+ const domain = await pluginDomainName(plugin);
496
+ const taken = await hostnameTaken(subdomain, domain, sites, tunnels);
483
497
  if (taken) throw refuse(`Cannot use '${subdomain}': ${taken}`, 409, 'hostname_taken');
484
498
 
485
499
  await serveApprovedTree(plugin, logger);
486
- const fqdn = `${subdomain}.${config.domain}`;
500
+ const fqdn = `${subdomain}.${domain}`;
487
501
  const cert = await deps.issueTunnelCert(fqdn, config.email);
488
502
  const site = ownedSiteEntry({ id: crypto.randomUUID(), subdomain, fqdn, plugin, accessMode });
489
503
  await writeSites([...sites, site]);
490
504
  try {
491
- const { name, config: vhost } = await renderOwnedSite(site, route, cert.certPath, tunnels);
505
+ const { name, config: vhost } = await renderOwnedSite(
506
+ site,
507
+ route,
508
+ cert.certPath,
509
+ tunnels,
510
+ domain,
511
+ );
492
512
  await deps.installVhost(name, vhost, fqdn, { enabled: true });
493
513
  } catch (err) {
494
514
  await writeSites(sites);
@@ -697,13 +717,10 @@ export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
697
717
  let wroteSites = false;
698
718
  let wroteTunnels = false;
699
719
  try {
700
- const config = getConfig();
701
- const installed = await deps.readInstalledVhost(
702
- `lamalibre-lamaste-app-${tunnel.subdomain}`,
703
- );
720
+ const { domain } = tunnelHost(tunnel);
721
+ const installed = await deps.readInstalledVhost(appVhostName(tunnel.fqdn));
704
722
  const certDir =
705
- (installed && vhostCertDir(installed)) ||
706
- (await deps.getCertPath(tunnel.fqdn, config.domain));
723
+ (installed && vhostCertDir(installed)) || (await deps.getCertPath(tunnel.fqdn, domain));
707
724
  // The state first — Gatekeeper reads both files on a change of
708
725
  // either, so the old vhost's API requests already resolve to the
709
726
  // site — then one reload hands the hostname over, then the port
@@ -712,7 +729,7 @@ export function moveWebSurfaceFromTunnel(route, tunnelId, { actor, logger }) {
712
729
  wroteSites = true;
713
730
  await writeTunnels(movedTunnels);
714
731
  wroteTunnels = true;
715
- await deps.moveAppVhostToWebSurface(tunnel.subdomain, site, certDir, config.domain, {
732
+ await deps.moveAppVhostToWebSurface(tunnel.fqdn, site, certDir, domain, {
716
733
  route,
717
734
  served: (await webSurfaceServing(route, movedTunnels)).served,
718
735
  maxBodySizeMb: await internalTunnelBodyLimit(plugin, movedTunnels),
@@ -1,6 +1,7 @@
1
1
  import fp from 'fastify-plugin';
2
2
  import { getConfig } from '../lib/config.js';
3
3
  import { findDirectoryUser } from '../lib/directory.js';
4
+ import { isPrimaryMember } from '../lib/domains.js';
4
5
  import {
5
6
  REFRESH_THRESHOLD,
6
7
  validateUserSession,
@@ -38,11 +39,17 @@ async function userAccessSessionPlugin(fastify, _opts) {
38
39
  return reply.code(401).send({ error: 'user_session_required' });
39
40
  }
40
41
 
41
- // The session's user must still hold the username, and be enabled: a
42
- // deleted user's username given to someone else never inherits it, and
43
- // a disabled user is out at once.
42
+ // The session's user must still hold the username, be enabled and be a
43
+ // member of the primary domain: a deleted user's username given to
44
+ // someone else never inherits it, and a disabled user, one whose
45
+ // membership ended or one made an external member is out at once.
44
46
  const holder = await findDirectoryUser(result.payload.username);
45
- if (!holder || holder.userId !== result.payload.userId || holder.status !== 'active') {
47
+ if (
48
+ !holder ||
49
+ holder.userId !== result.payload.userId ||
50
+ holder.status !== 'active' ||
51
+ !(await isPrimaryMember(holder.userId))
52
+ ) {
46
53
  return reply.code(401).send({ error: 'user_session_required' });
47
54
  }
48
55
 
@@ -8,9 +8,9 @@ import {
8
8
  generateTotpSecret,
9
9
  writeTotpToDatabase,
10
10
  } from '../lib/authelia.js';
11
- import { getConfig } from '../lib/config.js';
12
11
  import { httpError } from '../lib/app-error.js';
13
12
  import { changeUsers, usersFileError } from '../lib/directory.js';
13
+ import { invitationDomainsNow, invitationSignInDomain } from '../lib/domains.js';
14
14
 
15
15
  /**
16
16
  * Find an invitation by token using a constant-time comparison.
@@ -119,8 +119,11 @@ export default async function inviteRoutes(fastify, _opts) {
119
119
  return reply.code(500).send({ error: 'Failed to process password' });
120
120
  }
121
121
 
122
- // Through the users lock: the directory gives the user their id before
123
- // Authelia restarts and lets them sign in.
122
+ const grantedDomains = await invitationDomainsNow(invitation);
123
+
124
+ // Through the users lock: the directory gives the user their id, and
125
+ // the domains are rendered (their memberships, with the kinds the
126
+ // invitation named), before they can sign in.
124
127
  try {
125
128
  await changeUsers(
126
129
  async () => {
@@ -149,6 +152,12 @@ export default async function inviteRoutes(fastify, _opts) {
149
152
  // A removed user's grants, groups and second factors under the
150
153
  // username go first; a username with an OIDC identity is refused.
151
154
  creates: [invitation.username],
155
+ // The domains the invitation names that still are the ones it
156
+ // named (an invitation from before the multi-domain relay names
157
+ // none: the relay's one domain).
158
+ ...(Array.isArray(invitation.domains)
159
+ ? { domains: { [invitation.username]: grantedDomains } }
160
+ : {}),
152
161
  expect: (users) => users[invitation.username]?.password === hashedPassword,
153
162
  },
154
163
  );
@@ -191,8 +200,9 @@ export default async function inviteRoutes(fastify, _opts) {
191
200
  request.log.warn(err, 'Failed to update invitation status (user was created)');
192
201
  }
193
202
 
194
- const config = getConfig();
195
- const loginUrl = config.domain ? `https://auth.${config.domain}/` : null;
203
+ // The sign-in of the first domain the user is a member of.
204
+ const loginDomain = await invitationSignInDomain(invitation);
205
+ const loginUrl = loginDomain ? `https://auth.${loginDomain}/` : null;
196
206
 
197
207
  return {
198
208
  ok: true,