@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
@@ -6,6 +6,7 @@ import {
6
6
  RELAY_PANEL_PORT,
7
7
  USER_SURFACE_MAX_RESPONSE_SECONDS,
8
8
  isUserSurfaceRoute,
9
+ labelUnder,
9
10
  userSurfaceApiPath,
10
11
  WEB_TREE_CONTENT_TYPES,
11
12
  } from '@lamalibre/lamaste';
@@ -288,6 +289,46 @@ server {
288
289
  return { name: PANEL_DOMAIN_VHOST, fqdn, config };
289
290
  }
290
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
+
291
332
  /**
292
333
  * A hostname written into a vhost as a literal. Where it names the vhost to
293
334
  * an authorization subrequest it must stay a literal — a `$` would make nginx
@@ -336,61 +377,69 @@ const AUTHZ_CLEARED_HEADERS = `
336
377
  proxy_set_header X-Forwarded-Uri "";
337
378
  proxy_set_header X-Forwarded-Method "";`;
338
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
+
339
389
  /**
340
- * 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
341
395
  */
342
- export async function writeAuthVhost(domain) {
343
- const { name, config } = renderAuthVhost(domain);
344
- 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 });
345
399
  return path.join(SITES_AVAILABLE, name);
346
400
  }
347
401
 
348
402
  /**
349
- * Render the Authelia auth portal vhost. Pure; the startup reconcile renders
350
- * 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).
351
425
  *
352
426
  * @param {string} domain
353
427
  * @param {string} [certPath] - certificate directory (default: the portal's own lineage)
428
+ * @param {{ primary?: boolean }} [opts]
354
429
  * @returns {{ name: string, fqdn: string, config: string }}
355
430
  */
356
- export function renderAuthVhost(domain, certPath) {
431
+ export function renderAuthVhost(domain, certPath, { primary = true } = {}) {
357
432
  const fqdn = `auth.${domain}`;
358
433
  let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
359
434
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
360
- 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)
361
437
  limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
362
438
 
363
- server {
364
- listen 443 ssl;
365
- server_name ${fqdn};
366
-
367
- ssl_certificate ${certDirClean}/fullchain.pem;
368
- ssl_certificate_key ${certDirClean}/privkey.pem;
369
-
370
- ssl_protocols TLSv1.2 TLSv1.3;
371
- ssl_ciphers HIGH:!aNULL:!MD5;
372
- ssl_prefer_server_ciphers on;
373
-
374
- proxy_set_header Host $host;
375
- proxy_set_header X-Real-IP $remote_addr;
376
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
377
- proxy_set_header X-Forwarded-Proto $scheme;
378
-
379
- # Invitation page — static HTML
380
- location /invite/ {
381
- alias /var/www/lamaste/invite/;
382
- try_files $uri /invite/index.html;
383
- }
384
-
385
- # Invitation API — proxied to panel server (no mTLS)
386
- location /api/invite/ {
387
- proxy_pass http://127.0.0.1:3100/api/invite/;
388
- proxy_set_header Host $host;
389
- proxy_set_header X-Real-IP $remote_addr;
390
- proxy_set_header X-Forwarded-For $remote_addr;
391
- proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
392
- }
393
-
439
+ `
440
+ : '';
441
+ const userAccess = primary
442
+ ? `
394
443
  # User access authorization — Authelia forward auth protects this endpoint
395
444
  # so Remote-User header is set by Authelia on successful authentication.
396
445
  location /internal/authelia/authz {
@@ -434,15 +483,55 @@ server {
434
483
 
435
484
  error_page 401 =302 $redirection_url;
436
485
  }
486
+ `
487
+ : '';
488
+ const config = `${zone}server {
489
+ listen 443 ssl;
490
+ server_name ${fqdn};
437
491
 
438
- # Default — proxy to Authelia
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
+ }
509
+
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.)
439
523
  location / {
440
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;
441
530
  }
442
531
  }
443
532
  `;
444
533
 
445
- return { name: 'lamalibre-lamaste-auth', fqdn, config };
534
+ return { name: authVhostName(domain), fqdn, config };
446
535
  }
447
536
 
448
537
  /**
@@ -629,6 +718,11 @@ function gatekeeperSubrequest(check, fqdn) {
629
718
  * - `/internal/lamaste/user-surface-401` answers a request without a
630
719
  * session with Gatekeeper's JSON 401 — an application's request is never
631
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.
632
726
  *
633
727
  * Each route's locations pass the assertion, and nothing else of the
634
728
  * browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
@@ -654,6 +748,7 @@ function userSurfaceLocations(fqdn, userSurface) {
654
748
  const block = `{
655
749
  auth_request /internal/lamaste/user-surface-authz;
656
750
  auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
751
+ auth_request_set $lamaste_refusal $upstream_http_x_lamalibre_refusal;
657
752
 
658
753
  # The user, vouched for by Gatekeeper to the panel for this one request
659
754
  proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
@@ -668,8 +763,9 @@ ${USER_SURFACE_PROXY_HEADERS}
668
763
  proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
669
764
  proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
670
765
 
671
- # No session: a JSON 401, never a redirect
766
+ # No session: a JSON 401, never a redirect; refused: the refusal as JSON
672
767
  error_page 401 = /internal/lamaste/user-surface-401;
768
+ error_page 403 = /internal/lamaste/user-surface-403;
673
769
  }`;
674
770
  const apiPath = userSurfaceApiPath(route);
675
771
  return `
@@ -686,6 +782,17 @@ ${USER_SURFACE_PROXY_HEADERS}
686
782
  ${gatekeeperSubrequest('user-surface', fqdn)}
687
783
  }
688
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
+
689
796
  location = /internal/lamaste/user-surface-401 {
690
797
  internal;
691
798
  auth_request off;
@@ -709,13 +816,13 @@ ${routes.join('')}`;
709
816
  * Used for tunnels with accessMode='public'.
710
817
  */
711
818
  export async function writePublicVhost(
712
- subdomain,
819
+ fqdn,
713
820
  domain,
714
821
  port,
715
822
  certPath,
716
823
  { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
717
824
  ) {
718
- const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
825
+ const { name, config } = renderPublicVhost(fqdn, domain, port, certPath, {
719
826
  pathPrefix,
720
827
  maxBodySizeMb,
721
828
  userSurface,
@@ -724,7 +831,7 @@ export async function writePublicVhost(
724
831
  }
725
832
 
726
833
  function renderPublicVhost(
727
- subdomain,
834
+ fqdn,
728
835
  domain,
729
836
  port,
730
837
  certPath,
@@ -738,7 +845,7 @@ function renderPublicVhost(
738
845
  throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
739
846
  }
740
847
 
741
- const fqdn = `${subdomain}.${domain}`;
848
+ assertHostUnderDomain(fqdn, domain);
742
849
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
743
850
  let certDirClean = certDir;
744
851
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -776,63 +883,42 @@ ${TUNNEL_PROXY_HEADERS}
776
883
  }
777
884
  `;
778
885
 
779
- return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
780
- }
781
-
782
- /**
783
- * Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
784
- * Used for tunnels with accessMode='authenticated'.
785
- * Gatekeeper handles Authelia cookie validation and forwards identity headers.
786
- */
787
- export async function writeAuthenticatedVhost(
788
- subdomain,
789
- domain,
790
- port,
791
- certPath,
792
- { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
793
- ) {
794
- const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
795
- pathPrefix,
796
- maxBodySizeMb,
797
- userSurface,
798
- restricted: false,
799
- });
800
- return safeWriteVhost(name, config, fqdn, { enabled });
886
+ return { name: appVhostName(fqdn), fqdn, config };
801
887
  }
802
888
 
803
889
  /**
804
- * Write a restricted vhost — Authelia via Gatekeeper, only granted users/groups allowed.
805
- * Used for tunnels with accessMode='restricted'.
806
- * 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.
807
894
  */
808
- export async function writeRestrictedVhost(
809
- subdomain,
895
+ export async function writeGatekeeperVhost(
896
+ fqdn,
810
897
  domain,
811
898
  port,
812
899
  certPath,
813
900
  { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
814
901
  ) {
815
- const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
902
+ const { name, config } = renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
816
903
  pathPrefix,
817
904
  maxBodySizeMb,
818
905
  userSurface,
819
- restricted: true,
820
906
  });
821
907
  return safeWriteVhost(name, config, fqdn, { enabled });
822
908
  }
823
909
 
824
910
  function renderGatekeeperTunnelVhost(
825
- subdomain,
911
+ fqdn,
826
912
  domain,
827
913
  port,
828
914
  certPath,
829
- { pathPrefix, maxBodySizeMb, userSurface, restricted },
915
+ { pathPrefix, maxBodySizeMb, userSurface },
830
916
  ) {
831
917
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
832
918
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
833
919
  }
834
920
 
835
- const fqdn = `${subdomain}.${domain}`;
921
+ assertHostUnderDomain(fqdn, domain);
836
922
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
837
923
  let certDirClean = certDir;
838
924
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -841,10 +927,9 @@ function renderGatekeeperTunnelVhost(
841
927
  pathPrefix,
842
928
  maxBodySizeMb,
843
929
  userSurface,
844
- restricted,
845
930
  });
846
931
 
847
- return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
932
+ return { name: appVhostName(fqdn), fqdn, config };
848
933
  }
849
934
 
850
935
  /**
@@ -853,28 +938,27 @@ function renderGatekeeperTunnelVhost(
853
938
  * the generators produce today (see vhost-reconcile.js).
854
939
  *
855
940
  * @param {'public'|'authenticated'|'restricted'} accessMode
856
- * @param {string} subdomain
857
- * @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>`)
858
943
  * @param {number} port
859
944
  * @param {string} [certPath]
860
945
  * @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
861
946
  * `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
862
947
  * @returns {{ name: string, fqdn: string, config: string }}
863
948
  */
864
- export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
949
+ export function renderTunnelVhost(accessMode, fqdn, domain, port, certPath, opts = {}) {
865
950
  const { pathPrefix, maxBodySizeMb, userSurface } = opts;
866
951
  if (accessMode === 'public') {
867
- return renderPublicVhost(subdomain, domain, port, certPath, {
952
+ return renderPublicVhost(fqdn, domain, port, certPath, {
868
953
  pathPrefix,
869
954
  maxBodySizeMb,
870
955
  userSurface,
871
956
  });
872
957
  }
873
- return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
958
+ return renderGatekeeperTunnelVhost(fqdn, domain, port, certPath, {
874
959
  pathPrefix,
875
960
  maxBodySizeMb,
876
961
  userSurface,
877
- restricted: accessMode !== 'authenticated',
878
962
  });
879
963
  }
880
964
 
@@ -882,7 +966,8 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
882
966
  * Build a Gatekeeper-backed nginx vhost config string.
883
967
  * Both 'authenticated' and 'restricted' modes use this — the difference
884
968
  * is handled server-side by Gatekeeper based on the tunnel's accessMode.
885
- * 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.
886
971
  *
887
972
  * The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
888
973
  * written here as the agent-panel vhost writes its agent's label, and
@@ -904,7 +989,7 @@ function buildGatekeeperVhost(
904
989
  certDirClean,
905
990
  port,
906
991
  domain,
907
- { pathPrefix, maxBodySizeMb, userSurface = [], restricted } = {},
992
+ { pathPrefix, maxBodySizeMb, userSurface = [] } = {},
908
993
  ) {
909
994
  const url = originalUrl(fqdn);
910
995
  return `server {
@@ -965,12 +1050,9 @@ ${TUNNEL_PROXY_HEADERS}
965
1050
  # Authentication failure -> redirect to Authelia login, back to this vhost
966
1051
  error_page 401 =302 https://auth.${domain}/?rd=${url};
967
1052
 
968
- ${
969
- restricted
970
- ? `# Authorization failure -> Gatekeeper serves inline access-request page
971
- error_page 403 = /internal/lamaste/authz;`
972
- : ''
973
- }
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;
974
1056
  }
975
1057
  }
976
1058
  `;
@@ -1084,7 +1166,8 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
1084
1166
  * @param {object} site - as for writeStaticSiteVhost
1085
1167
  * @param {string} certDir
1086
1168
  * @param {string} [domain]
1087
- * @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
1088
1171
  */
1089
1172
  export function renderStaticSiteVhost(site, certDir, domain) {
1090
1173
  let certDirClean = certDir;
@@ -1094,8 +1177,13 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1094
1177
 
1095
1178
  let autheliaBlock = '';
1096
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;
1097
1185
 
1098
- if (site.autheliaProtected && domain) {
1186
+ if (gated) {
1099
1187
  autheliaBlock = `
1100
1188
  # Authelia forward authentication (AuthRequest implementation for nginx)
1101
1189
  location /internal/authelia/authz {
@@ -1115,6 +1203,23 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1115
1203
  proxy_buffers 4 32k;
1116
1204
  proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
1117
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
+ }
1118
1223
  `;
1119
1224
  locationAuthDirectives = `
1120
1225
  # Clear client-supplied identity headers (Authelia re-injects on success)
@@ -1151,20 +1256,21 @@ export function renderStaticSiteVhost(site, certDir, domain) {
1151
1256
  add_header X-Content-Type-Options nosniff always;
1152
1257
  ${autheliaBlock}
1153
1258
  location / {${locationAuthDirectives}
1154
- ${tryFiles};
1259
+ ${closed ? 'return 403' : tryFiles};
1155
1260
  }
1156
1261
  ${
1157
- site.autheliaProtected && domain
1262
+ gated
1158
1263
  ? `
1159
1264
  # Redirect unauthenticated requests to Authelia login portal
1160
1265
  error_page 401 =302 $redirection_url;
1266
+ error_page 403 = /internal/lamaste/refusal;
1161
1267
  `
1162
1268
  : ''
1163
1269
  }
1164
1270
  }
1165
1271
  ${aliasBlock}`;
1166
1272
 
1167
- return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
1273
+ return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config, closed };
1168
1274
  }
1169
1275
 
1170
1276
  /**
@@ -1400,34 +1506,34 @@ async function handOverVhost(fromName, name, config, fqdn) {
1400
1506
  }
1401
1507
 
1402
1508
  /**
1403
- * Unlink tunnel vhost `subdomain` when it is still enabled although a
1509
+ * Unlink the vhost of tunnel `fqdn` when it is still enabled although a
1404
1510
  * plugin's site holds its hostname — a move the panel did not finish (it
1405
1511
  * wrote the state, then stopped before the handover). Does not reload:
1406
1512
  * the caller's next vhost install does, or it reloads itself. Returns
1407
1513
  * whether it unlinked.
1408
1514
  *
1409
- * @param {string} subdomain the moved tunnel's
1515
+ * @param {string} fqdn the moved tunnel's hostname
1410
1516
  */
1411
- export async function unlinkMovedAppVhost(subdomain) {
1412
- const name = `lamalibre-lamaste-app-${subdomain}`;
1517
+ export async function unlinkMovedAppVhost(fqdn) {
1518
+ const name = appVhostName(fqdn);
1413
1519
  if (!(await isSiteEnabled(name))) return false;
1414
1520
  await disableSite(name);
1415
1521
  return true;
1416
1522
  }
1417
1523
 
1418
1524
  /**
1419
- * Serve a plugin's web surface on the hostname of tunnel vhost `subdomain`
1420
- * in one reload (see {@link handOverVhost}).
1525
+ * Serve a plugin's web surface on the hostname of tunnel `fqdn` in one
1526
+ * reload (see {@link handOverVhost}).
1421
1527
  *
1422
- * @param {string} subdomain the tunnel's
1528
+ * @param {string} fqdn the tunnel's hostname
1423
1529
  * @param {{ id: string, fqdn: string }} site
1424
1530
  * @param {string} certDir
1425
1531
  * @param {string} domain
1426
1532
  * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1427
1533
  */
1428
- export async function moveAppVhostToWebSurface(subdomain, site, certDir, domain, opts) {
1429
- const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1430
- await handOverVhost(`lamalibre-lamaste-app-${subdomain}`, name, config, fqdn);
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);
1431
1537
  }
1432
1538
 
1433
1539
  /**
@@ -1488,10 +1594,10 @@ export async function removeStaticSiteVhost(siteId) {
1488
1594
  * Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
1489
1595
  * Idempotent: if files don't exist, proceeds silently.
1490
1596
  *
1491
- * @param {string} subdomain - The subdomain name
1597
+ * @param {string} fqdn - The tunnel hostname
1492
1598
  */
1493
- export async function removeAppVhost(subdomain) {
1494
- const name = `lamalibre-lamaste-app-${subdomain}`;
1599
+ export async function removeAppVhost(fqdn) {
1600
+ const name = appVhostName(fqdn);
1495
1601
  await siteAction('remove', name);
1496
1602
 
1497
1603
  const result = await testConfig();
@@ -1544,10 +1650,10 @@ export async function disableSite(name) {
1544
1650
  /**
1545
1651
  * Enable an app tunnel vhost (restore symlink), test and reload nginx.
1546
1652
  *
1547
- * @param {string} subdomain - The subdomain name
1653
+ * @param {string} fqdn - The tunnel hostname
1548
1654
  */
1549
- export async function enableAppVhost(subdomain) {
1550
- const name = `lamalibre-lamaste-app-${subdomain}`;
1655
+ export async function enableAppVhost(fqdn) {
1656
+ const name = appVhostName(fqdn);
1551
1657
  await enableSite(name);
1552
1658
 
1553
1659
  const result = await testConfig();
@@ -1563,10 +1669,10 @@ export async function enableAppVhost(subdomain) {
1563
1669
  /**
1564
1670
  * Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
1565
1671
  *
1566
- * @param {string} subdomain - The subdomain name
1672
+ * @param {string} fqdn - The tunnel hostname
1567
1673
  */
1568
- export async function disableAppVhost(subdomain) {
1569
- const name = `lamalibre-lamaste-app-${subdomain}`;
1674
+ export async function disableAppVhost(fqdn) {
1675
+ const name = appVhostName(fqdn);
1570
1676
  await disableSite(name);
1571
1677
 
1572
1678
  const result = await testConfig();
@@ -1612,19 +1718,13 @@ export async function enableIpVhost() {
1612
1718
  *
1613
1719
  * Written through safeWriteVhost (backup, test, rollback).
1614
1720
  *
1615
- * @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
1721
+ * @param {string} fqdn - The hostname (e.g., "agent-my-agent.example.com")
1616
1722
  * @param {string} domain - The base domain (e.g., "example.com")
1617
1723
  * @param {number} port - The local port to proxy to
1618
1724
  * @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
1619
1725
  */
1620
- export async function writeAgentPanelVhost(
1621
- subdomain,
1622
- domain,
1623
- port,
1624
- certPath,
1625
- { enabled = true } = {},
1626
- ) {
1627
- 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);
1628
1728
  return safeWriteVhost(name, config, fqdn, { enabled });
1629
1729
  }
1630
1730
 
@@ -1646,13 +1746,14 @@ export async function writeAgentPanelVhost(
1646
1746
  * cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
1647
1747
  * is under the base domain like every other.
1648
1748
  *
1649
- * @param {string} subdomain - `agent-<label>`
1749
+ * @param {string} fqdn - `agent-<label>.<domain>`
1650
1750
  * @param {string} domain
1651
1751
  * @param {number} port
1652
1752
  * @param {string} [certPath]
1653
1753
  * @returns {{ name: string, fqdn: string, config: string }}
1654
1754
  */
1655
- export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1755
+ export function renderAgentPanelVhost(fqdn, domain, port, certPath) {
1756
+ const subdomain = assertHostUnderDomain(fqdn, domain);
1656
1757
  if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
1657
1758
  throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
1658
1759
  }
@@ -1660,7 +1761,6 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1660
1761
  throw new Error(`Invalid port for nginx vhost: ${port}`);
1661
1762
  }
1662
1763
  const label = subdomain.slice('agent-'.length);
1663
- const fqdn = `${subdomain}.${domain}`;
1664
1764
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
1665
1765
  let certDirClean = certDir;
1666
1766
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
@@ -1750,7 +1850,7 @@ ${BACKEND_COOKIE_HEADER}
1750
1850
  }
1751
1851
  `;
1752
1852
 
1753
- return { name: `lamalibre-lamaste-agent-panel-${subdomain}`, fqdn, config };
1853
+ return { name: agentPanelVhostName(fqdn), fqdn, config };
1754
1854
  }
1755
1855
 
1756
1856
  /**
@@ -1791,10 +1891,10 @@ export { safeWriteVhost as installVhost };
1791
1891
  * Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
1792
1892
  * Idempotent: if files don't exist, proceeds silently.
1793
1893
  *
1794
- * @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")
1795
1895
  */
1796
- export async function removeAgentPanelVhost(subdomain) {
1797
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1896
+ export async function removeAgentPanelVhost(fqdn) {
1897
+ const name = agentPanelVhostName(fqdn);
1798
1898
  await siteAction('remove', name);
1799
1899
 
1800
1900
  const result = await testConfig();
@@ -1808,10 +1908,10 @@ export async function removeAgentPanelVhost(subdomain) {
1808
1908
  /**
1809
1909
  * Enable an agent panel vhost (restore symlink), test and reload nginx.
1810
1910
  *
1811
- * @param {string} subdomain - The subdomain name
1911
+ * @param {string} fqdn - The tunnel hostname
1812
1912
  */
1813
- export async function enableAgentPanelVhost(subdomain) {
1814
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1913
+ export async function enableAgentPanelVhost(fqdn) {
1914
+ const name = agentPanelVhostName(fqdn);
1815
1915
  await enableSite(name);
1816
1916
 
1817
1917
  const result = await testConfig();
@@ -1826,10 +1926,10 @@ export async function enableAgentPanelVhost(subdomain) {
1826
1926
  /**
1827
1927
  * Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
1828
1928
  *
1829
- * @param {string} subdomain - The subdomain name
1929
+ * @param {string} fqdn - The tunnel hostname
1830
1930
  */
1831
- export async function disableAgentPanelVhost(subdomain) {
1832
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1931
+ export async function disableAgentPanelVhost(fqdn) {
1932
+ const name = agentPanelVhostName(fqdn);
1833
1933
  await disableSite(name);
1834
1934
 
1835
1935
  const result = await testConfig();
@@ -1840,3 +1940,81 @@ export async function disableAgentPanelVhost(subdomain) {
1840
1940
 
1841
1941
  await reload();
1842
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
+ }