@lamalibre/create-lamaste 4.5.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  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/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -6,7 +6,9 @@ import {
6
6
  RELAY_PANEL_PORT,
7
7
  USER_SURFACE_MAX_RESPONSE_SECONDS,
8
8
  isUserSurfaceRoute,
9
+ labelUnder,
9
10
  userSurfaceApiPath,
11
+ WEB_TREE_CONTENT_TYPES,
10
12
  } from '@lamalibre/lamaste';
11
13
 
12
14
  const SITES_AVAILABLE = '/etc/nginx/sites-available';
@@ -287,6 +289,46 @@ server {
287
289
  return { name: PANEL_DOMAIN_VHOST, fqdn, config };
288
290
  }
289
291
 
292
+ /** The site-name prefixes of the vhosts named by a hostname (lamaste-priv checks the same). */
293
+ const APP_VHOST_PREFIX = 'lamalibre-lamaste-app-';
294
+ const AGENT_PANEL_VHOST_PREFIX = 'lamalibre-lamaste-agent-panel-';
295
+ const AUTH_VHOST_PREFIX = 'lamalibre-lamaste-auth-';
296
+
297
+ /** Site name of an app or plugin tunnel's vhost: its full hostname. */
298
+ export function appVhostName(fqdn) {
299
+ assertLiteralHostname(fqdn);
300
+ return `${APP_VHOST_PREFIX}${fqdn.toLowerCase()}`;
301
+ }
302
+
303
+ /** Site name of an agent panel tunnel's vhost: its full hostname. */
304
+ export function agentPanelVhostName(fqdn) {
305
+ assertLiteralHostname(fqdn);
306
+ return `${AGENT_PANEL_VHOST_PREFIX}${fqdn.toLowerCase()}`;
307
+ }
308
+
309
+ /**
310
+ * Site names of tunnel vhosts before the multi-domain relay, by subdomain
311
+ * alone (one domain): the startup reconcile renames them
312
+ * (vhost-reconcile.js).
313
+ */
314
+ export function legacyTunnelVhostName(type, subdomain) {
315
+ return `${type === 'panel' ? AGENT_PANEL_VHOST_PREFIX : APP_VHOST_PREFIX}${subdomain}`;
316
+ }
317
+
318
+ /**
319
+ * The label of tunnel hostname `fqdn` under `domain` — one label, as a
320
+ * tunnel's is — or a refusal.
321
+ */
322
+ function assertHostUnderDomain(fqdn, domain) {
323
+ assertLiteralHostname(fqdn);
324
+ assertLiteralHostname(domain);
325
+ const label = labelUnder(fqdn, domain);
326
+ if (label === null || label.includes('.')) {
327
+ throw new Error(`Hostname ${fqdn} is not a subdomain of ${domain}`);
328
+ }
329
+ return label;
330
+ }
331
+
290
332
  /**
291
333
  * A hostname written into a vhost as a literal. Where it names the vhost to
292
334
  * an authorization subrequest it must stay a literal — a `$` would make nginx
@@ -335,61 +377,69 @@ const AUTHZ_CLEARED_HEADERS = `
335
377
  proxy_set_header X-Forwarded-Uri "";
336
378
  proxy_set_header X-Forwarded-Method "";`;
337
379
 
380
+ /** Site name of the auth portal vhost of `domain` (`auth.<domain>`). */
381
+ export function authVhostName(domain) {
382
+ assertLiteralHostname(domain);
383
+ return `${AUTH_VHOST_PREFIX}${domain.toLowerCase()}`;
384
+ }
385
+
386
+ /** Site name of the auth portal vhost before the multi-domain relay (the primary's alone). */
387
+ export const LEGACY_AUTH_VHOST = 'lamalibre-lamaste-auth';
388
+
338
389
  /**
339
- * Write the Authelia auth portal vhost.
390
+ * Write the Authelia auth portal vhost of one of the relay's domains
391
+ * (`primary`: the panel's — see {@link renderAuthVhost}).
392
+ *
393
+ * @param {string} domain
394
+ * @param {{ primary: boolean, enabled?: boolean }} opts
340
395
  */
341
- export async function writeAuthVhost(domain) {
342
- const { name, config } = renderAuthVhost(domain);
343
- await writeVhostFile(name, config);
396
+ export async function writeAuthVhost(domain, { primary, enabled = true }) {
397
+ const { name, config } = renderAuthVhost(domain, undefined, { primary });
398
+ await safeWriteVhost(name, config, `auth.${domain}`, { enabled });
344
399
  return path.join(SITES_AVAILABLE, name);
345
400
  }
346
401
 
347
402
  /**
348
- * Render the Authelia auth portal vhost. Pure; the startup reconcile renders
349
- * it on the certificate directory the installed vhost names.
403
+ * Remove the auth portal vhost of a removed domain, then test and reload:
404
+ * `auth.<domain>` stops being served at once.
405
+ */
406
+ export async function removeAuthVhost(domain) {
407
+ const name = authVhostName(domain);
408
+ await siteAction('remove', name);
409
+
410
+ const result = await testConfig();
411
+ if (!result.valid) {
412
+ throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
413
+ }
414
+
415
+ await reload();
416
+ }
417
+
418
+ /**
419
+ * Render the Authelia auth portal vhost of one domain. Pure; the startup
420
+ * reconcile renders it on the certificate directory the installed vhost
421
+ * names. Every domain's portal serves Authelia and the invitation page; only
422
+ * the primary's — the panel's domain, which the desktop's user login
423
+ * exchanges at — serves `/api/user-access/authorize` and declares its rate
424
+ * limit zone (an http-level name, declared once).
350
425
  *
351
426
  * @param {string} domain
352
427
  * @param {string} [certPath] - certificate directory (default: the portal's own lineage)
428
+ * @param {{ primary?: boolean }} [opts]
353
429
  * @returns {{ name: string, fqdn: string, config: string }}
354
430
  */
355
- export function renderAuthVhost(domain, certPath) {
431
+ export function renderAuthVhost(domain, certPath, { primary = true } = {}) {
356
432
  const fqdn = `auth.${domain}`;
357
433
  let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
358
434
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
359
- const config = `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
435
+ const zone = primary
436
+ ? `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
360
437
  limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
361
438
 
362
- server {
363
- listen 443 ssl;
364
- server_name ${fqdn};
365
-
366
- ssl_certificate ${certDirClean}/fullchain.pem;
367
- ssl_certificate_key ${certDirClean}/privkey.pem;
368
-
369
- ssl_protocols TLSv1.2 TLSv1.3;
370
- ssl_ciphers HIGH:!aNULL:!MD5;
371
- ssl_prefer_server_ciphers on;
372
-
373
- proxy_set_header Host $host;
374
- proxy_set_header X-Real-IP $remote_addr;
375
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
376
- proxy_set_header X-Forwarded-Proto $scheme;
377
-
378
- # Invitation page — static HTML
379
- location /invite/ {
380
- alias /var/www/lamaste/invite/;
381
- try_files $uri /invite/index.html;
382
- }
383
-
384
- # Invitation API — proxied to panel server (no mTLS)
385
- location /api/invite/ {
386
- proxy_pass http://127.0.0.1:3100/api/invite/;
387
- proxy_set_header Host $host;
388
- proxy_set_header X-Real-IP $remote_addr;
389
- proxy_set_header X-Forwarded-For $remote_addr;
390
- proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
391
- }
392
-
439
+ `
440
+ : '';
441
+ const userAccess = primary
442
+ ? `
393
443
  # User access authorization — Authelia forward auth protects this endpoint
394
444
  # so Remote-User header is set by Authelia on successful authentication.
395
445
  location /internal/authelia/authz {
@@ -433,15 +483,55 @@ server {
433
483
 
434
484
  error_page 401 =302 $redirection_url;
435
485
  }
486
+ `
487
+ : '';
488
+ const config = `${zone}server {
489
+ listen 443 ssl;
490
+ server_name ${fqdn};
491
+
492
+ ssl_certificate ${certDirClean}/fullchain.pem;
493
+ ssl_certificate_key ${certDirClean}/privkey.pem;
494
+
495
+ ssl_protocols TLSv1.2 TLSv1.3;
496
+ ssl_ciphers HIGH:!aNULL:!MD5;
497
+ ssl_prefer_server_ciphers on;
498
+
499
+ proxy_set_header Host $host;
500
+ proxy_set_header X-Real-IP $remote_addr;
501
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
502
+ proxy_set_header X-Forwarded-Proto $scheme;
503
+
504
+ # Invitation page — static HTML
505
+ location /invite/ {
506
+ alias /var/www/lamaste/invite/;
507
+ try_files $uri /invite/index.html;
508
+ }
436
509
 
437
- # Default — proxy to Authelia
510
+ # Invitation API — proxied to panel server (no mTLS)
511
+ location /api/invite/ {
512
+ proxy_pass http://127.0.0.1:3100/api/invite/;
513
+ proxy_set_header Host $host;
514
+ proxy_set_header X-Real-IP $remote_addr;
515
+ proxy_set_header X-Forwarded-For $remote_addr;
516
+ proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
517
+ }
518
+ ${userAccess}
519
+ # Default — proxy to Authelia. Authelia takes the OIDC issuer and the
520
+ # session cookie's domain from X-Forwarded-Host / -Proto: this portal's
521
+ # own name, never what the client sent. (A location that sets any
522
+ # header inherits none of the server's.)
438
523
  location / {
439
524
  proxy_pass http://127.0.0.1:9091;
525
+ proxy_set_header Host ${fqdn};
526
+ proxy_set_header X-Real-IP $remote_addr;
527
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
528
+ proxy_set_header X-Forwarded-Host ${fqdn};
529
+ proxy_set_header X-Forwarded-Proto https;
440
530
  }
441
531
  }
442
532
  `;
443
533
 
444
- return { name: 'lamalibre-lamaste-auth', fqdn, config };
534
+ return { name: authVhostName(domain), fqdn, config };
445
535
  }
446
536
 
447
537
  /**
@@ -590,12 +680,14 @@ const USER_SURFACE_PROXY_HEADERS = `
590
680
  * `X-Original-URL` and in `Host` — never by the client's `Host` header (see
591
681
  * {@link originalUrl}) — and the session cookie is passed on.
592
682
  *
593
- * @param {'tunnel'|'user-surface'} check
683
+ * @param {'tunnel'|'user-surface'|'user-surface-shell'} check
594
684
  * @param {string} fqdn
595
685
  * @returns {string}
596
686
  */
597
687
  function gatekeeperSubrequest(check, fqdn) {
598
688
  return `internal;
689
+ # A subrequest is never authorized itself (a host may authorize at server level)
690
+ auth_request off;
599
691
 
600
692
  proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
601
693
  proxy_pass_request_body off;
@@ -626,6 +718,11 @@ function gatekeeperSubrequest(check, fqdn) {
626
718
  * - `/internal/lamaste/user-surface-401` answers a request without a
627
719
  * session with Gatekeeper's JSON 401 — an application's request is never
628
720
  * redirected to the login page.
721
+ * - `/internal/lamaste/user-surface-403` answers a refused request with its
722
+ * refusal as JSON: under `auth_request` nginx keeps only the subrequest's
723
+ * status and headers, never its body, so Gatekeeper names the refusal in
724
+ * `X-Lamalibre-Refusal` and the location answers `not_a_member` for that
725
+ * value and `forbidden` for anything else.
629
726
  *
630
727
  * Each route's locations pass the assertion, and nothing else of the
631
728
  * browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
@@ -651,6 +748,7 @@ function userSurfaceLocations(fqdn, userSurface) {
651
748
  const block = `{
652
749
  auth_request /internal/lamaste/user-surface-authz;
653
750
  auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
751
+ auth_request_set $lamaste_refusal $upstream_http_x_lamalibre_refusal;
654
752
 
655
753
  # The user, vouched for by Gatekeeper to the panel for this one request
656
754
  proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
@@ -665,15 +763,16 @@ ${USER_SURFACE_PROXY_HEADERS}
665
763
  proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
666
764
  proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
667
765
 
668
- # No session: a JSON 401, never a redirect
766
+ # No session: a JSON 401, never a redirect; refused: the refusal as JSON
669
767
  error_page 401 = /internal/lamaste/user-surface-401;
768
+ error_page 403 = /internal/lamaste/user-surface-403;
670
769
  }`;
671
770
  const apiPath = userSurfaceApiPath(route);
672
771
  return `
673
772
  # The user surface of plugin "${route}"
674
773
  location = ${apiPath} ${block}
675
774
 
676
- location ${apiPath}/ ${block}
775
+ location ^~ ${apiPath}/ ${block}
677
776
  `;
678
777
  });
679
778
  return `
@@ -683,8 +782,20 @@ ${USER_SURFACE_PROXY_HEADERS}
683
782
  ${gatekeeperSubrequest('user-surface', fqdn)}
684
783
  }
685
784
 
785
+ location = /internal/lamaste/user-surface-403 {
786
+ internal;
787
+ default_type application/json;
788
+ add_header Cache-Control "no-store" always;
789
+ add_header X-Content-Type-Options "nosniff" always;
790
+ if ($lamaste_refusal = not_a_member) {
791
+ return 403 '{"error":"not_a_member"}';
792
+ }
793
+ return 403 '{"error":"forbidden"}';
794
+ }
795
+
686
796
  location = /internal/lamaste/user-surface-401 {
687
797
  internal;
798
+ auth_request off;
688
799
 
689
800
  proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
690
801
  proxy_pass_request_body off;
@@ -705,13 +816,13 @@ ${routes.join('')}`;
705
816
  * Used for tunnels with accessMode='public'.
706
817
  */
707
818
  export async function writePublicVhost(
708
- subdomain,
819
+ fqdn,
709
820
  domain,
710
821
  port,
711
822
  certPath,
712
823
  { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
713
824
  ) {
714
- const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
825
+ const { name, config } = renderPublicVhost(fqdn, domain, port, certPath, {
715
826
  pathPrefix,
716
827
  maxBodySizeMb,
717
828
  userSurface,
@@ -720,7 +831,7 @@ export async function writePublicVhost(
720
831
  }
721
832
 
722
833
  function renderPublicVhost(
723
- subdomain,
834
+ fqdn,
724
835
  domain,
725
836
  port,
726
837
  certPath,
@@ -734,7 +845,7 @@ function renderPublicVhost(
734
845
  throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
735
846
  }
736
847
 
737
- const fqdn = `${subdomain}.${domain}`;
848
+ assertHostUnderDomain(fqdn, domain);
738
849
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
739
850
  let certDirClean = certDir;
740
851
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -772,63 +883,42 @@ ${TUNNEL_PROXY_HEADERS}
772
883
  }
773
884
  `;
774
885
 
775
- return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
776
- }
777
-
778
- /**
779
- * Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
780
- * Used for tunnels with accessMode='authenticated'.
781
- * Gatekeeper handles Authelia cookie validation and forwards identity headers.
782
- */
783
- export async function writeAuthenticatedVhost(
784
- subdomain,
785
- domain,
786
- port,
787
- certPath,
788
- { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
789
- ) {
790
- const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
791
- pathPrefix,
792
- maxBodySizeMb,
793
- userSurface,
794
- restricted: false,
795
- });
796
- return safeWriteVhost(name, config, fqdn, { enabled });
886
+ return { name: appVhostName(fqdn), fqdn, config };
797
887
  }
798
888
 
799
889
  /**
800
- * Write a restricted vhost — Authelia via Gatekeeper, only granted users/groups allowed.
801
- * Used for tunnels with accessMode='restricted'.
802
- * On 403, Gatekeeper returns the inline access-request HTML page.
890
+ * Write the vhost of an `authenticated` or `restricted` tunnel — one and
891
+ * the same: nginx asks Gatekeeper about every request, and Gatekeeper
892
+ * decides by the tunnel's access mode in tunnels.json (any member of the
893
+ * domain, or only those with a grant) and serves its refusal pages inline.
803
894
  */
804
- export async function writeRestrictedVhost(
805
- subdomain,
895
+ export async function writeGatekeeperVhost(
896
+ fqdn,
806
897
  domain,
807
898
  port,
808
899
  certPath,
809
900
  { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
810
901
  ) {
811
- const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
902
+ const { name, config } = renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
812
903
  pathPrefix,
813
904
  maxBodySizeMb,
814
905
  userSurface,
815
- restricted: true,
816
906
  });
817
907
  return safeWriteVhost(name, config, fqdn, { enabled });
818
908
  }
819
909
 
820
910
  function renderGatekeeperTunnelVhost(
821
- subdomain,
911
+ fqdn,
822
912
  domain,
823
913
  port,
824
914
  certPath,
825
- { pathPrefix, maxBodySizeMb, userSurface, restricted },
915
+ { pathPrefix, maxBodySizeMb, userSurface },
826
916
  ) {
827
917
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
828
918
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
829
919
  }
830
920
 
831
- const fqdn = `${subdomain}.${domain}`;
921
+ assertHostUnderDomain(fqdn, domain);
832
922
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
833
923
  let certDirClean = certDir;
834
924
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -837,10 +927,9 @@ function renderGatekeeperTunnelVhost(
837
927
  pathPrefix,
838
928
  maxBodySizeMb,
839
929
  userSurface,
840
- restricted,
841
930
  });
842
931
 
843
- return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
932
+ return { name: appVhostName(fqdn), fqdn, config };
844
933
  }
845
934
 
846
935
  /**
@@ -849,28 +938,27 @@ function renderGatekeeperTunnelVhost(
849
938
  * the generators produce today (see vhost-reconcile.js).
850
939
  *
851
940
  * @param {'public'|'authenticated'|'restricted'} accessMode
852
- * @param {string} subdomain
853
- * @param {string} domain
941
+ * @param {string} fqdn the tunnel's hostname
942
+ * @param {string} domain the base domain it is under (its sign-in, `auth.<domain>`)
854
943
  * @param {number} port
855
944
  * @param {string} [certPath]
856
945
  * @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
857
946
  * `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
858
947
  * @returns {{ name: string, fqdn: string, config: string }}
859
948
  */
860
- export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
949
+ export function renderTunnelVhost(accessMode, fqdn, domain, port, certPath, opts = {}) {
861
950
  const { pathPrefix, maxBodySizeMb, userSurface } = opts;
862
951
  if (accessMode === 'public') {
863
- return renderPublicVhost(subdomain, domain, port, certPath, {
952
+ return renderPublicVhost(fqdn, domain, port, certPath, {
864
953
  pathPrefix,
865
954
  maxBodySizeMb,
866
955
  userSurface,
867
956
  });
868
957
  }
869
- return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
958
+ return renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
870
959
  pathPrefix,
871
960
  maxBodySizeMb,
872
961
  userSurface,
873
- restricted: accessMode !== 'authenticated',
874
962
  });
875
963
  }
876
964
 
@@ -878,7 +966,8 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
878
966
  * Build a Gatekeeper-backed nginx vhost config string.
879
967
  * Both 'authenticated' and 'restricted' modes use this — the difference
880
968
  * is handled server-side by Gatekeeper based on the tunnel's accessMode.
881
- * For restricted mode, the 403 body from Gatekeeper is served inline.
969
+ * Gatekeeper's 403 body is served inline: a restricted tunnel's access
970
+ * request, or the refusal page of a user who is no member of the domain.
882
971
  *
883
972
  * The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
884
973
  * written here as the agent-panel vhost writes its agent's label, and
@@ -900,7 +989,7 @@ function buildGatekeeperVhost(
900
989
  certDirClean,
901
990
  port,
902
991
  domain,
903
- { pathPrefix, maxBodySizeMb, userSurface = [], restricted } = {},
992
+ { pathPrefix, maxBodySizeMb, userSurface = [] } = {},
904
993
  ) {
905
994
  const url = originalUrl(fqdn);
906
995
  return `server {
@@ -961,12 +1050,9 @@ ${TUNNEL_PROXY_HEADERS}
961
1050
  # Authentication failure -> redirect to Authelia login, back to this vhost
962
1051
  error_page 401 =302 https://auth.${domain}/?rd=${url};
963
1052
 
964
- ${
965
- restricted
966
- ? `# Authorization failure -> Gatekeeper serves inline access-request page
967
- error_page 403 = /internal/lamaste/authz;`
968
- : ''
969
- }
1053
+ # Authorization failure -> Gatekeeper's inline page: the access
1054
+ # request of a restricted tunnel, or not a member of this domain
1055
+ error_page 403 = /internal/lamaste/authz;
970
1056
  }
971
1057
  }
972
1058
  `;
@@ -1080,7 +1166,8 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
1080
1166
  * @param {object} site - as for writeStaticSiteVhost
1081
1167
  * @param {string} certDir
1082
1168
  * @param {string} [domain]
1083
- * @returns {{ name: string, fqdn: string, config: string }}
1169
+ * @returns {{ name: string, fqdn: string, config: string, closed: boolean }} `closed`: a
1170
+ * protected site under no domain, answering 403 to everyone
1084
1171
  */
1085
1172
  export function renderStaticSiteVhost(site, certDir, domain) {
1086
1173
  let certDirClean = certDir;
@@ -1090,8 +1177,13 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1090
1177
 
1091
1178
  let autheliaBlock = '';
1092
1179
  let locationAuthDirectives = '';
1180
+ // A protected site asks its domain's sign-in; one under none of the
1181
+ // relay's domains has no sign-in that could admit anyone: it is closed,
1182
+ // never served without authentication.
1183
+ const gated = site.autheliaProtected && Boolean(domain);
1184
+ const closed = site.autheliaProtected && !domain;
1093
1185
 
1094
- if (site.autheliaProtected && domain) {
1186
+ if (gated) {
1095
1187
  autheliaBlock = `
1096
1188
  # Authelia forward authentication (AuthRequest implementation for nginx)
1097
1189
  location /internal/authelia/authz {
@@ -1111,6 +1203,23 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1111
1203
  proxy_buffers 4 32k;
1112
1204
  proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
1113
1205
  }
1206
+
1207
+ # Signed in, refused (not a member of the site's domain, or not one of
1208
+ # its users): Gatekeeper's page says so instead of nginx's bare 403
1209
+ location = /internal/lamaste/refusal {
1210
+ internal;
1211
+ auth_request off;
1212
+
1213
+ proxy_pass http://127.0.0.1:9294/authz/refusal/${site.fqdn};
1214
+ proxy_pass_request_body off;
1215
+
1216
+ proxy_set_header Content-Length "";
1217
+ proxy_set_header Cookie "";
1218
+ proxy_set_header Authorization "";
1219
+ proxy_set_header X-Forwarded-For $remote_addr;
1220
+
1221
+ proxy_http_version 1.1;
1222
+ }
1114
1223
  `;
1115
1224
  locationAuthDirectives = `
1116
1225
  # Clear client-supplied identity headers (Authelia re-injects on success)
@@ -1147,22 +1256,286 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1147
1256
  add_header X-Content-Type-Options nosniff always;
1148
1257
  ${autheliaBlock}
1149
1258
  location / {${locationAuthDirectives}
1150
- ${tryFiles};
1259
+ ${closed ? 'return 403' : tryFiles};
1151
1260
  }
1152
1261
  ${
1153
- site.autheliaProtected && domain
1262
+ gated
1154
1263
  ? `
1155
1264
  # Redirect unauthenticated requests to Authelia login portal
1156
1265
  error_page 401 =302 $redirection_url;
1266
+ error_page 403 = /internal/lamaste/refusal;
1157
1267
  `
1158
1268
  : ''
1159
1269
  }
1160
1270
  }
1161
1271
  ${aliasBlock}`;
1162
1272
 
1273
+ return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config, closed };
1274
+ }
1275
+
1276
+ /**
1277
+ * The Content-Security-Policy of a plugin's web surface: its shell loads
1278
+ * scripts, styles, fonts and images from its own host only and talks to its
1279
+ * own host only (its user surface's API is there). Inline styles are
1280
+ * allowed — frameworks set `style` attributes and insert component styles
1281
+ * at run time — inline scripts never; no framing, no plugins, no foreign
1282
+ * form targets.
1283
+ */
1284
+ const WEB_SURFACE_CSP =
1285
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
1286
+
1287
+ /**
1288
+ * Response headers of the shell of a plugin's web surface, set in its
1289
+ * locations — not at server level, where the API's answers (headed by the
1290
+ * panel) would get a second copy. `no-cache`: a browser revalidates each file —
1291
+ * a 304 while the tree is the same, the new file once an update switched
1292
+ * it — so no page mixes two versions of the shell from its cache.
1293
+ */
1294
+ const WEB_SURFACE_HEADERS = `
1295
+ add_header Content-Security-Policy "${WEB_SURFACE_CSP}" always;
1296
+ add_header X-Content-Type-Options nosniff always;
1297
+ add_header X-Frame-Options DENY always;
1298
+ add_header Referrer-Policy same-origin always;
1299
+ add_header Cross-Origin-Opener-Policy same-origin always;
1300
+ add_header Cross-Origin-Resource-Policy same-origin always;
1301
+ add_header Cache-Control no-cache always;`;
1302
+
1303
+ /**
1304
+ * The content types of a web tree's files, by extension
1305
+ * (`WEB_TREE_CONTENT_TYPES` — the only extensions a tree may hold).
1306
+ */
1307
+ const WEB_SURFACE_TYPES = (() => {
1308
+ const byType = new Map();
1309
+ for (const [ext, type] of Object.entries(WEB_TREE_CONTENT_TYPES)) {
1310
+ byType.set(type, [...(byType.get(type) ?? []), ext.slice(1)]);
1311
+ }
1312
+ const entries = [...byType].map(([type, exts]) => ` ${type} ${exts.join(' ')};`);
1313
+ return `
1314
+ types {
1315
+ ${entries.join('\n')}
1316
+ }`;
1317
+ })();
1318
+
1319
+ /**
1320
+ * The files a page still running the previous shell may ask for after an
1321
+ * update — its scripts, styles, fonts and images — and nothing else: a
1322
+ * document, an SVG or a manifest is never served from the previous tree.
1323
+ */
1324
+ const WEB_SURFACE_SUBRESOURCE_RE = '[.](?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|ico)$';
1325
+
1326
+ /**
1327
+ * Service workers: never registered from a plugin's site. A worker outlives
1328
+ * the tree that installed it — a revert, the site's deletion, the
1329
+ * hostname's next owner — and answers in its place; asked for its script
1330
+ * (`Service-Worker: script`), the site answers 404, which also
1331
+ * unregisters a worker already installed at its next update check.
1332
+ */
1333
+ const NO_SERVICE_WORKER = `
1334
+ if ($http_service_worker) {
1335
+ return 404;
1336
+ }`;
1337
+
1338
+ /**
1339
+ * Render the vhost of a plugin's web surface — a site the plugin owns
1340
+ * (`site.owner`). Pure; the startup reconcile renders it again to compare.
1341
+ *
1342
+ * While the plugin's surface is served (`opts.served`: its installed
1343
+ * version approved, declaring identity and a web surface, hosted), the
1344
+ * host serves two things under one Authelia session:
1345
+ *
1346
+ * - **the shell**: every path but the API, from the plugin's current tree
1347
+ * (`<treesDir>/current`), GET and HEAD only, each file under its own
1348
+ * content type (`WEB_SURFACE_TYPES`), never a service worker's script. Every request asks
1349
+ * Gatekeeper first (`/authz/user-surface-shell/<fqdn>`: a session in the
1350
+ * plugin's audience, and the site's grant when it is restricted) — a page
1351
+ * without a session goes to the login, a user without access gets the
1352
+ * access-request page. A script, style, font or image the current tree
1353
+ * lacks is looked for in the previous tree (a page still running the old
1354
+ * shell loads its lazy files after an update), else 404; any other
1355
+ * unknown path gets the current shell's `index.html`.
1356
+ * - **the API**: `/api/<route>/app/*`, exactly as on a bound tunnel
1357
+ * ({@link userSurfaceLocations}).
1358
+ *
1359
+ * While it is not served the host answers every request with 503 — and,
1360
+ * once its plugin is no longer installed (`opts.clearSiteData`), tells
1361
+ * browsers to clear the site's storage.
1362
+ *
1363
+ * @param {{ id: string, fqdn: string }} site
1364
+ * @param {string} certDir
1365
+ * @param {string} domain
1366
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string, clearSiteData?: boolean }} opts
1367
+ * @returns {{ name: string, fqdn: string, config: string }}
1368
+ */
1369
+ export function renderWebSurfaceVhost(site, certDir, domain, opts) {
1370
+ const { route, served, maxBodySizeMb, treesDir, clearSiteData = false } = opts;
1371
+ assertLiteralHostname(site.fqdn);
1372
+ if (!isUserSurfaceRoute(route)) throw new Error(`Invalid web surface route: ${route}`);
1373
+ if (!path.isAbsolute(treesDir) || !/^[A-Za-z0-9/._-]+$/.test(treesDir)) {
1374
+ throw new Error(`Invalid web tree directory for nginx vhost: ${treesDir}`);
1375
+ }
1376
+ let certDirClean = certDir;
1377
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
1378
+
1379
+ const head = `server {
1380
+ listen 443 ssl;
1381
+ server_name ${site.fqdn};
1382
+
1383
+ ssl_certificate ${certDirClean}/fullchain.pem;
1384
+ ssl_certificate_key ${certDirClean}/privkey.pem;
1385
+
1386
+ ssl_protocols TLSv1.2 TLSv1.3;
1387
+ ssl_ciphers HIGH:!aNULL:!MD5;
1388
+ ssl_prefer_server_ciphers on;
1389
+ `;
1390
+
1391
+ if (!served) {
1392
+ // A plugin no longer installed: whatever comes next on this hostname is
1393
+ // not its shell, so browsers drop what it left — its storage, caches
1394
+ // and service workers. Never while the plugin is only unhosted or
1395
+ // awaiting approval: its users' data stays.
1396
+ const clear = clearSiteData
1397
+ ? `
1398
+ add_header Clear-Site-Data '"storage"' always;`
1399
+ : '';
1400
+ return {
1401
+ name: `lamalibre-lamaste-site-${site.id}`,
1402
+ fqdn: site.fqdn,
1403
+ config: `${head}
1404
+ # The web surface of plugin "${route}" is not served now (not installed,
1405
+ # not approved, not hosted, or its version declares no web surface)
1406
+ location / {${NO_SERVICE_WORKER}
1407
+ add_header Cache-Control no-store always;${clear}
1408
+ return 503;
1409
+ }
1410
+ }
1411
+ `,
1412
+ };
1413
+ }
1414
+
1415
+ const url = originalUrl(site.fqdn);
1416
+ const config = `${head}
1417
+ root ${treesDir}/current;
1418
+ index index.html;
1419
+
1420
+ # Every path of the shell: Gatekeeper first (the API's locations ask on their own)
1421
+ auth_request /internal/lamaste/shell-authz;
1422
+ error_page 401 =302 https://auth.${domain}/?rd=${url};
1423
+ error_page 403 = /internal/lamaste/shell-authz;
1424
+
1425
+ location = /internal/lamaste/shell-authz {
1426
+ ${gatekeeperSubrequest('user-surface-shell', site.fqdn)}
1427
+ }
1428
+ ${userSurfaceLocations(site.fqdn, [{ route, maxBodySizeMb }])}
1429
+ # A script, style, font or image the current tree lacks: the previous
1430
+ # tree's (a page still running the old shell), else 404
1431
+ location ~* ${WEB_SURFACE_SUBRESOURCE_RE} {
1432
+ if ($request_method !~ ^(GET|HEAD)$) {
1433
+ return 405;
1434
+ }${NO_SERVICE_WORKER}
1435
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1436
+ try_files $uri @previous;
1437
+ }
1438
+
1439
+ # Everything else: the current tree's file, else the shell
1440
+ location / {
1441
+ if ($request_method !~ ^(GET|HEAD)$) {
1442
+ return 405;
1443
+ }${NO_SERVICE_WORKER}
1444
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1445
+ try_files $uri /index.html;
1446
+ }
1447
+
1448
+ location @previous {
1449
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1450
+ root ${treesDir}/previous;
1451
+ try_files $uri =404;
1452
+ }
1453
+ }
1454
+ `;
1163
1455
  return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
1164
1456
  }
1165
1457
 
1458
+ /**
1459
+ * Write the vhost of a plugin's web surface (see {@link renderWebSurfaceVhost}).
1460
+ *
1461
+ * @param {{ id: string, fqdn: string }} site
1462
+ * @param {string} certDir
1463
+ * @param {string} domain
1464
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1465
+ */
1466
+ export async function writeWebSurfaceVhost(site, certDir, domain, opts) {
1467
+ const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1468
+ return safeWriteVhost(name, config, fqdn, { enabled: true });
1469
+ }
1470
+
1471
+ /**
1472
+ * Hand a hostname from one vhost to another in one nginx reload: vhost
1473
+ * `fromName` is unlinked, `name` written with `config` and linked, the
1474
+ * configuration tested and nginx reloaded once; `fromName` stays installed,
1475
+ * unlinked. Until the reload nginx serves the old vhost, after it the new
1476
+ * one; there is no moment in between. On any failure before the reload
1477
+ * both are put back as they were.
1478
+ *
1479
+ * @param {string} fromName the vhost giving the hostname up (linked)
1480
+ * @param {string} name a vhost that does not exist yet
1481
+ * @param {string} config
1482
+ * @param {string} fqdn
1483
+ */
1484
+ async function handOverVhost(fromName, name, config, fqdn) {
1485
+ if (await siteExists(name)) throw new Error(`Vhost ${name} exists already`);
1486
+ const fromEnabled = await isSiteEnabled(fromName);
1487
+ const restore = async () => {
1488
+ await siteAction('remove', name).catch(() => {});
1489
+ if (fromEnabled) await enableSite(fromName).catch(() => {});
1490
+ };
1491
+ try {
1492
+ await disableSite(fromName);
1493
+ await writeVhostFile(name, config);
1494
+ await enableSite(name);
1495
+ const result = await testConfig();
1496
+ if (!result.valid) {
1497
+ throw new Error(`Nginx config test failed handing ${fqdn} to ${name}: ${result.error}`);
1498
+ }
1499
+ await reload();
1500
+ } catch (err) {
1501
+ await restore();
1502
+ throw err;
1503
+ }
1504
+ // `fromName` stays installed, only unlinked: the tunnel it served can be
1505
+ // enabled again by hand once the new vhost is gone.
1506
+ }
1507
+
1508
+ /**
1509
+ * Unlink the vhost of tunnel `fqdn` when it is still enabled although a
1510
+ * plugin's site holds its hostname — a move the panel did not finish (it
1511
+ * wrote the state, then stopped before the handover). Does not reload:
1512
+ * the caller's next vhost install does, or it reloads itself. Returns
1513
+ * whether it unlinked.
1514
+ *
1515
+ * @param {string} fqdn the moved tunnel's hostname
1516
+ */
1517
+ export async function unlinkMovedAppVhost(fqdn) {
1518
+ const name = appVhostName(fqdn);
1519
+ if (!(await isSiteEnabled(name))) return false;
1520
+ await disableSite(name);
1521
+ return true;
1522
+ }
1523
+
1524
+ /**
1525
+ * Serve a plugin's web surface on the hostname of tunnel `fqdn` in one
1526
+ * reload (see {@link handOverVhost}).
1527
+ *
1528
+ * @param {string} fqdn the tunnel's hostname
1529
+ * @param {{ id: string, fqdn: string }} site
1530
+ * @param {string} certDir
1531
+ * @param {string} domain
1532
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1533
+ */
1534
+ export async function moveAppVhostToWebSurface(fqdn, site, certDir, domain, opts) {
1535
+ const { name, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1536
+ await handOverVhost(appVhostName(fqdn), name, config, site.fqdn);
1537
+ }
1538
+
1166
1539
  /**
1167
1540
  * Server block answering a site's aliases with a permanent redirect to the
1168
1541
  * primary hostname, on the same certificate (its lineage covers every alias).
@@ -1221,10 +1594,10 @@ export async function removeStaticSiteVhost(siteId) {
1221
1594
  * Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
1222
1595
  * Idempotent: if files don't exist, proceeds silently.
1223
1596
  *
1224
- * @param {string} subdomain - The subdomain name
1597
+ * @param {string} fqdn - The tunnel hostname
1225
1598
  */
1226
- export async function removeAppVhost(subdomain) {
1227
- const name = `lamalibre-lamaste-app-${subdomain}`;
1599
+ export async function removeAppVhost(fqdn) {
1600
+ const name = appVhostName(fqdn);
1228
1601
  await siteAction('remove', name);
1229
1602
 
1230
1603
  const result = await testConfig();
@@ -1277,10 +1650,10 @@ export async function disableSite(name) {
1277
1650
  /**
1278
1651
  * Enable an app tunnel vhost (restore symlink), test and reload nginx.
1279
1652
  *
1280
- * @param {string} subdomain - The subdomain name
1653
+ * @param {string} fqdn - The tunnel hostname
1281
1654
  */
1282
- export async function enableAppVhost(subdomain) {
1283
- const name = `lamalibre-lamaste-app-${subdomain}`;
1655
+ export async function enableAppVhost(fqdn) {
1656
+ const name = appVhostName(fqdn);
1284
1657
  await enableSite(name);
1285
1658
 
1286
1659
  const result = await testConfig();
@@ -1296,10 +1669,10 @@ export async function enableAppVhost(subdomain) {
1296
1669
  /**
1297
1670
  * Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
1298
1671
  *
1299
- * @param {string} subdomain - The subdomain name
1672
+ * @param {string} fqdn - The tunnel hostname
1300
1673
  */
1301
- export async function disableAppVhost(subdomain) {
1302
- const name = `lamalibre-lamaste-app-${subdomain}`;
1674
+ export async function disableAppVhost(fqdn) {
1675
+ const name = appVhostName(fqdn);
1303
1676
  await disableSite(name);
1304
1677
 
1305
1678
  const result = await testConfig();
@@ -1345,19 +1718,13 @@ export async function enableIpVhost() {
1345
1718
  *
1346
1719
  * Written through safeWriteVhost (backup, test, rollback).
1347
1720
  *
1348
- * @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
1721
+ * @param {string} fqdn - The hostname (e.g., "agent-my-agent.example.com")
1349
1722
  * @param {string} domain - The base domain (e.g., "example.com")
1350
1723
  * @param {number} port - The local port to proxy to
1351
1724
  * @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
1352
1725
  */
1353
- export async function writeAgentPanelVhost(
1354
- subdomain,
1355
- domain,
1356
- port,
1357
- certPath,
1358
- { enabled = true } = {},
1359
- ) {
1360
- const { name, fqdn, config } = renderAgentPanelVhost(subdomain, domain, port, certPath);
1726
+ export async function writeAgentPanelVhost(fqdn, domain, port, certPath, { enabled = true } = {}) {
1727
+ const { name, config } = renderAgentPanelVhost(fqdn, domain, port, certPath);
1361
1728
  return safeWriteVhost(name, config, fqdn, { enabled });
1362
1729
  }
1363
1730
 
@@ -1379,13 +1746,14 @@ export async function writeAgentPanelVhost(
1379
1746
  * cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
1380
1747
  * is under the base domain like every other.
1381
1748
  *
1382
- * @param {string} subdomain - `agent-<label>`
1749
+ * @param {string} fqdn - `agent-<label>.<domain>`
1383
1750
  * @param {string} domain
1384
1751
  * @param {number} port
1385
1752
  * @param {string} [certPath]
1386
1753
  * @returns {{ name: string, fqdn: string, config: string }}
1387
1754
  */
1388
- export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1755
+ export function renderAgentPanelVhost(fqdn, domain, port, certPath) {
1756
+ const subdomain = assertHostUnderDomain(fqdn, domain);
1389
1757
  if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
1390
1758
  throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
1391
1759
  }
@@ -1393,7 +1761,6 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1393
1761
  throw new Error(`Invalid port for nginx vhost: ${port}`);
1394
1762
  }
1395
1763
  const label = subdomain.slice('agent-'.length);
1396
- const fqdn = `${subdomain}.${domain}`;
1397
1764
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
1398
1765
  let certDirClean = certDir;
1399
1766
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -1483,7 +1850,7 @@ ${BACKEND_COOKIE_HEADER}
1483
1850
  }
1484
1851
  `;
1485
1852
 
1486
- return { name: `lamalibre-lamaste-agent-panel-${subdomain}`, fqdn, config };
1853
+ return { name: agentPanelVhostName(fqdn), fqdn, config };
1487
1854
  }
1488
1855
 
1489
1856
  /**
@@ -1524,10 +1891,10 @@ export { safeWriteVhost as installVhost };
1524
1891
  * Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
1525
1892
  * Idempotent: if files don't exist, proceeds silently.
1526
1893
  *
1527
- * @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
1894
+ * @param {string} fqdn - The agent panel hostname (e.g., "agent-my-agent.example.com")
1528
1895
  */
1529
- export async function removeAgentPanelVhost(subdomain) {
1530
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1896
+ export async function removeAgentPanelVhost(fqdn) {
1897
+ const name = agentPanelVhostName(fqdn);
1531
1898
  await siteAction('remove', name);
1532
1899
 
1533
1900
  const result = await testConfig();
@@ -1541,10 +1908,10 @@ export async function removeAgentPanelVhost(subdomain) {
1541
1908
  /**
1542
1909
  * Enable an agent panel vhost (restore symlink), test and reload nginx.
1543
1910
  *
1544
- * @param {string} subdomain - The subdomain name
1911
+ * @param {string} fqdn - The tunnel hostname
1545
1912
  */
1546
- export async function enableAgentPanelVhost(subdomain) {
1547
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1913
+ export async function enableAgentPanelVhost(fqdn) {
1914
+ const name = agentPanelVhostName(fqdn);
1548
1915
  await enableSite(name);
1549
1916
 
1550
1917
  const result = await testConfig();
@@ -1559,10 +1926,10 @@ export async function enableAgentPanelVhost(subdomain) {
1559
1926
  /**
1560
1927
  * Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
1561
1928
  *
1562
- * @param {string} subdomain - The subdomain name
1929
+ * @param {string} fqdn - The tunnel hostname
1563
1930
  */
1564
- export async function disableAgentPanelVhost(subdomain) {
1565
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1931
+ export async function disableAgentPanelVhost(fqdn) {
1932
+ const name = agentPanelVhostName(fqdn);
1566
1933
  await disableSite(name);
1567
1934
 
1568
1935
  const result = await testConfig();
@@ -1573,3 +1940,81 @@ export async function disableAgentPanelVhost(subdomain) {
1573
1940
 
1574
1941
  await reload();
1575
1942
  }
1943
+
1944
+ /** The file operations {@link renameVhosts} uses (tests pass stand-ins). */
1945
+ const renameFileOps = {
1946
+ readInstalledVhost,
1947
+ siteExists,
1948
+ isSiteEnabled,
1949
+ writeVhostFile,
1950
+ enableSite,
1951
+ disableSite,
1952
+ removeSite: (name) => siteAction('remove', name),
1953
+ testConfig,
1954
+ reload,
1955
+ };
1956
+
1957
+ /**
1958
+ * Rename installed vhosts — `[{ from, to, serverName? }]` — in one nginx
1959
+ * reload: each `from` that exists is written again under `to` with the same
1960
+ * text and link state, and unlinked; then `nginx -t`. When it passes, nginx
1961
+ * reloads and the old files go. When it fails — or a step does — every new
1962
+ * file goes, every old link comes back, nothing reloads, and the error is
1963
+ * thrown. The multi-domain relay's start-up migration of vhosts named by
1964
+ * subdomain to vhosts named by hostname.
1965
+ *
1966
+ * A `from` whose `to` exists already — a migration a crash cut short — is
1967
+ * finished, not skipped: `to` takes `from`'s link and `from` goes, so a
1968
+ * stale old file never outlives its tunnel. `serverName`, when given, must
1969
+ * be the old file's `server_name`: a file that serves another host is
1970
+ * nobody's to rename.
1971
+ *
1972
+ * @param {ReadonlyArray<{ from: string, to: string, serverName?: string }>} renames
1973
+ * @param {typeof renameFileOps} [ops]
1974
+ * @returns {Promise<string[]>} the `to` names written or finished
1975
+ */
1976
+ export async function renameVhosts(renames, ops = renameFileOps) {
1977
+ const done = [];
1978
+ try {
1979
+ for (const { from, to, serverName } of renames) {
1980
+ const text = await ops.readInstalledVhost(from);
1981
+ if (text === null) continue;
1982
+ if (serverName !== undefined && !servesHost(text, serverName)) continue;
1983
+ const linked = await ops.isSiteEnabled(from);
1984
+ if (await ops.siteExists(to)) {
1985
+ const linkTo = linked && !(await ops.isSiteEnabled(to));
1986
+ done.push({ from, to, linked, existed: true, linkedTo: linkTo });
1987
+ if (linkTo) await ops.enableSite(to);
1988
+ if (linked) await ops.disableSite(from);
1989
+ continue;
1990
+ }
1991
+ done.push({ from, to, linked, existed: false, linkedTo: linked });
1992
+ await ops.writeVhostFile(to, text);
1993
+ if (linked) {
1994
+ await ops.enableSite(to);
1995
+ await ops.disableSite(from);
1996
+ }
1997
+ }
1998
+ if (done.length === 0) return [];
1999
+ const result = await ops.testConfig();
2000
+ if (!result.valid) throw new Error(`nginx -t failed after renaming vhosts: ${result.error}`);
2001
+ await ops.reload();
2002
+ } catch (err) {
2003
+ for (const { from, to, linked, existed, linkedTo } of done.reverse()) {
2004
+ if (!existed) await ops.removeSite(to).catch(() => {});
2005
+ else if (linkedTo) await ops.disableSite(to).catch(() => {});
2006
+ if (linked) await ops.enableSite(from).catch(() => {});
2007
+ }
2008
+ throw err;
2009
+ }
2010
+ for (const { from } of done) {
2011
+ await ops.removeSite(from).catch(() => {});
2012
+ }
2013
+ return done.map((d) => d.to);
2014
+ }
2015
+
2016
+ /** True when vhost `text`'s first `server_name` is exactly `host`. */
2017
+ function servesHost(text, host) {
2018
+ const match = /^\s*server_name\s+([^;\s]+)\s*;/m.exec(text);
2019
+ return match !== null && match[1].toLowerCase() === host.toLowerCase();
2020
+ }