@lamalibre/create-lamaste 4.4.1 → 4.6.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 (170) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +234 -18
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  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 +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/02-guides/static-sites.md +4 -0
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +3 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
  32. package/vendor/docs/03-architecture/overview.md +13 -17
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +3 -2
  35. package/vendor/docs/03-architecture/state-management.md +4 -8
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
  38. package/vendor/docs/04-api-reference/app-host.md +3 -3
  39. package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
  40. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  41. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  42. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  43. package/vendor/docs/04-api-reference/sites.md +4 -0
  44. package/vendor/docs/04-api-reference/tunnels.md +87 -47
  45. package/vendor/docs/04-api-reference/users.md +241 -45
  46. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  47. package/vendor/docs/05-operations/monitoring.md +39 -0
  48. package/vendor/docs/05-operations/uninstalling.md +2 -1
  49. package/vendor/docs/05-operations/upgrades.md +24 -6
  50. package/vendor/docs/06-reference/config-files.md +100 -202
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  54. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  55. package/vendor/docs/gatekeeper-design.md +10 -8
  56. package/vendor/docs/identity-directory-design.md +48 -14
  57. package/vendor/docs/package.json +1 -1
  58. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  59. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  60. package/vendor/gatekeeper/dist/index.d.ts +4 -3
  61. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/index.js +4 -2
  63. package/vendor/gatekeeper/dist/index.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  65. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  67. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  69. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  71. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  72. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  73. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  74. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  75. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  76. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  77. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  79. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  81. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  82. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  83. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  84. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
  85. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  86. package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
  87. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  88. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  89. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  90. package/vendor/gatekeeper/dist/server/index.js +49 -56
  91. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  92. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  93. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  94. package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
  95. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  96. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  98. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  100. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  101. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  102. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  103. package/vendor/gatekeeper/package.json +2 -2
  104. package/vendor/server/bin/lamaste-server.js +15 -1
  105. package/vendor/server/package.json +2 -2
  106. package/vendor/server/src/commands/plugin-identity.js +335 -0
  107. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  108. package/vendor/server/src/commands/plugins.js +156 -11
  109. package/vendor/server/src/commands/registry-credential.js +2 -1
  110. package/vendor/server/src/commands/sites.js +15 -1
  111. package/vendor/server/src/commands/tunnels.js +102 -2
  112. package/vendor/server/src/commands/uninstall.js +3 -1
  113. package/vendor/server/src/commands/users.js +358 -0
  114. package/vendor/server/src/plugin-approval.js +186 -5
  115. package/vendor/server/src/plugin-identity.js +382 -0
  116. package/vendor/server/src/plugin-web-surface.js +307 -0
  117. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  118. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  119. package/vendor/server-ui/dist/index.html +2 -2
  120. package/vendor/serverd/package.json +2 -2
  121. package/vendor/serverd/src/index.js +34 -5
  122. package/vendor/serverd/src/lib/audience.js +576 -0
  123. package/vendor/serverd/src/lib/authelia.js +78 -7
  124. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  125. package/vendor/serverd/src/lib/chisel.js +10 -2
  126. package/vendor/serverd/src/lib/config.js +68 -43
  127. package/vendor/serverd/src/lib/directory.js +1113 -0
  128. package/vendor/serverd/src/lib/entitlements.js +517 -0
  129. package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
  130. package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
  131. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  132. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  133. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  134. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  135. package/vendor/serverd/src/lib/mtls.js +6 -2
  136. package/vendor/serverd/src/lib/nginx.js +546 -48
  137. package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
  138. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  139. package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
  140. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  141. package/vendor/serverd/src/lib/plugins.js +95 -7
  142. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  143. package/vendor/serverd/src/lib/request-logging.js +19 -0
  144. package/vendor/serverd/src/lib/retained-data.js +50 -4
  145. package/vendor/serverd/src/lib/state.js +36 -0
  146. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  147. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  148. package/vendor/serverd/src/lib/user-caller.js +67 -0
  149. package/vendor/serverd/src/lib/user-surface.js +754 -0
  150. package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
  151. package/vendor/serverd/src/lib/web-surface.js +766 -0
  152. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  153. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  154. package/vendor/serverd/src/routes/invite.js +53 -12
  155. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  156. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  157. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  158. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  159. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  160. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  161. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  162. package/vendor/serverd/src/routes/management/sites.js +143 -65
  163. package/vendor/serverd/src/routes/management/tunnels.js +109 -22
  164. package/vendor/serverd/src/routes/management/users.js +290 -208
  165. package/vendor/serverd/src/routes/management.js +6 -0
  166. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  167. package/vendor/serverd/src/routes/user-access.js +17 -4
  168. package/vendor/serverd/src/routes/user-surface.js +69 -0
  169. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  170. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -1,6 +1,14 @@
1
1
  import { execa } from 'execa';
2
2
  import { access, lstat, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import {
5
+ BACKEND_COOKIE_VARIABLE,
6
+ RELAY_PANEL_PORT,
7
+ USER_SURFACE_MAX_RESPONSE_SECONDS,
8
+ isUserSurfaceRoute,
9
+ userSurfaceApiPath,
10
+ WEB_TREE_CONTENT_TYPES,
11
+ } from '@lamalibre/lamaste';
4
12
 
5
13
  const SITES_AVAILABLE = '/etc/nginx/sites-available';
6
14
  const SITES_ENABLED = '/etc/nginx/sites-enabled';
@@ -58,11 +66,41 @@ async function writeVhostFile(name, content) {
58
66
  await siteAction('write', name, content);
59
67
  }
60
68
 
69
+ /** Site name of the panel's domain vhost (`panel.<domain>`). */
70
+ export const PANEL_DOMAIN_VHOST = 'lamalibre-lamaste-panel-domain';
71
+
72
+ /**
73
+ * Cleared in every location that proxies a browser's request to the panel
74
+ * itself: a relay assertion reaches the panel only from a user-surface
75
+ * location of a tunnel vhost, which sets it from Gatekeeper's answer — never
76
+ * one a client sent.
77
+ */
78
+ const PANEL_ASSERTION_CLEARED = `
79
+ # A relay assertion comes only from Gatekeeper, on a plugin's user surface
80
+ proxy_set_header X-Lamalibre-Relay-Assertion "";`;
81
+
61
82
  /**
62
83
  * Write the panel domain vhost with mTLS and Let's Encrypt certs.
63
84
  */
64
85
  export async function writePanelVhost(domain) {
86
+ const { name, config } = renderPanelVhost(domain);
87
+ await writeVhostFile(name, config);
88
+ return path.join(SITES_AVAILABLE, name);
89
+ }
90
+
91
+ /**
92
+ * Render the panel domain vhost. Pure; the startup reconcile renders it on
93
+ * the certificate directory the installed vhost names.
94
+ *
95
+ * @param {string} domain
96
+ * @param {string} [certPath] - certificate directory (default: the panel's own lineage)
97
+ * @returns {{ name: string, fqdn: string, config: string }}
98
+ */
99
+ export function renderPanelVhost(domain, certPath) {
65
100
  const fqdn = `panel.${domain}`;
101
+ assertLiteralHostname(fqdn);
102
+ let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
103
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
66
104
  const config = `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
67
105
  limit_req_zone $binary_remote_addr zone=enroll_domain:1m rate=5r/m;
68
106
 
@@ -76,8 +114,8 @@ server {
76
114
  listen 443 ssl;
77
115
  server_name ${fqdn};
78
116
 
79
- ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
80
- ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
117
+ ssl_certificate ${certDirClean}/fullchain.pem;
118
+ ssl_certificate_key ${certDirClean}/privkey.pem;
81
119
 
82
120
  # mTLS — same as IP-based access
83
121
  include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
@@ -103,7 +141,7 @@ server {
103
141
  proxy_set_header Remote-User "";
104
142
  proxy_set_header Remote-Groups "";
105
143
  proxy_set_header Remote-Name "";
106
- proxy_set_header Remote-Email "";
144
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
107
145
 
108
146
  # Standard proxy headers
109
147
  proxy_set_header Host $host;
@@ -126,7 +164,7 @@ server {
126
164
  proxy_set_header Remote-User "";
127
165
  proxy_set_header Remote-Groups "";
128
166
  proxy_set_header Remote-Name "";
129
- proxy_set_header Remote-Email "";
167
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
130
168
 
131
169
  proxy_set_header Host $host;
132
170
  proxy_set_header X-Real-IP $remote_addr;
@@ -146,7 +184,7 @@ server {
146
184
  proxy_set_header Remote-User "";
147
185
  proxy_set_header Remote-Groups "";
148
186
  proxy_set_header Remote-Name "";
149
- proxy_set_header Remote-Email "";
187
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
150
188
 
151
189
  proxy_set_header Host $host;
152
190
  proxy_set_header X-Real-IP $remote_addr;
@@ -167,7 +205,7 @@ server {
167
205
  proxy_set_header Remote-User "";
168
206
  proxy_set_header Remote-Groups "";
169
207
  proxy_set_header Remote-Name "";
170
- proxy_set_header Remote-Email "";
208
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
171
209
 
172
210
  proxy_set_header Host $host;
173
211
  proxy_set_header X-Real-IP $remote_addr;
@@ -187,7 +225,7 @@ server {
187
225
  proxy_set_header Remote-User "";
188
226
  proxy_set_header Remote-Groups "";
189
227
  proxy_set_header Remote-Name "";
190
- proxy_set_header Remote-Email "";
228
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
191
229
 
192
230
  proxy_set_header Host $host;
193
231
  proxy_set_header X-Real-IP $remote_addr;
@@ -207,7 +245,7 @@ server {
207
245
  proxy_set_header Remote-User "";
208
246
  proxy_set_header Remote-Groups "";
209
247
  proxy_set_header Remote-Name "";
210
- proxy_set_header Remote-Email "";
248
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
211
249
 
212
250
  proxy_set_header Host $host;
213
251
  proxy_set_header X-Real-IP $remote_addr;
@@ -232,7 +270,7 @@ server {
232
270
  proxy_set_header Remote-User "";
233
271
  proxy_set_header Remote-Groups "";
234
272
  proxy_set_header Remote-Name "";
235
- proxy_set_header Remote-Email "";
273
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
236
274
 
237
275
  # Standard proxy headers
238
276
  proxy_set_header Host $host;
@@ -247,8 +285,7 @@ server {
247
285
  }
248
286
  `;
249
287
 
250
- await writeVhostFile('lamalibre-lamaste-panel-domain', config);
251
- return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
288
+ return { name: PANEL_DOMAIN_VHOST, fqdn, config };
252
289
  }
253
290
 
254
291
  /**
@@ -351,7 +388,7 @@ server {
351
388
  proxy_set_header Host $host;
352
389
  proxy_set_header X-Real-IP $remote_addr;
353
390
  proxy_set_header X-Forwarded-For $remote_addr;
354
- proxy_set_header X-Forwarded-Proto $scheme;
391
+ proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
355
392
  }
356
393
 
357
394
  # User access authorization — Authelia forward auth protects this endpoint
@@ -393,7 +430,7 @@ server {
393
430
  proxy_set_header Host $host;
394
431
  proxy_set_header X-Real-IP $remote_addr;
395
432
  proxy_set_header X-Forwarded-For $remote_addr;
396
- proxy_set_header X-Forwarded-Proto $scheme;
433
+ proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
397
434
 
398
435
  error_page 401 =302 $redirection_url;
399
436
  }
@@ -446,6 +483,23 @@ export async function writeTunnelVhost(domain) {
446
483
  return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-tunnel');
447
484
  }
448
485
 
486
+ /**
487
+ * The `Cookie` a tunnel or agent backend receives: the browser's cookies
488
+ * without Authelia's session cookie. Authelia scopes that cookie to the base
489
+ * domain, so the browser sends it to every tunnel host; a backend that got
490
+ * it — any application on any agent, or whatever holds a tunnel port on the
491
+ * relay — could replay it as the visitor on every authenticated host and
492
+ * every plugin's user surface. `$lamaste_backend_cookie` is defined by the
493
+ * http-level map create-lamaste installs (core `renderBackendCookieMap`,
494
+ * `/etc/nginx/conf.d/lamalibre-lamaste-backend-cookie.conf`): every
495
+ * `authelia_session` pair removed, or no `Cookie` at all when one could not
496
+ * be. A vhost written before the map exists fails `nginx -t` and is rolled
497
+ * back — never served with the cookie.
498
+ */
499
+ const BACKEND_COOKIE_HEADER = `
500
+ # Never Authelia's session cookie: the browser's other cookies only
501
+ proxy_set_header Cookie ${BACKEND_COOKIE_VARIABLE};`;
502
+
449
503
  /**
450
504
  * Request headers every tunnel location sends upstream.
451
505
  *
@@ -457,7 +511,8 @@ export async function writeTunnelVhost(domain) {
457
511
  * The client-certificate headers are cleared. The panel trusts
458
512
  * `X-SSL-Client-*` from nginx as proof of an mTLS client certificate; a
459
513
  * tunnel vhost must never pass client-supplied values through, whatever
460
- * listens on its port.
514
+ * listens on its port. Authelia's session cookie never reaches the backend
515
+ * ({@link BACKEND_COOKIE_HEADER}).
461
516
  */
462
517
  const TUNNEL_PROXY_HEADERS = `
463
518
  proxy_set_header Host $host;
@@ -469,6 +524,7 @@ const TUNNEL_PROXY_HEADERS = `
469
524
  proxy_set_header X-SSL-Client-Verify "";
470
525
  proxy_set_header X-SSL-Client-DN "";
471
526
  proxy_set_header X-SSL-Client-Serial "";
527
+ ${BACKEND_COOKIE_HEADER}
472
528
 
473
529
  # WebSocket support
474
530
  proxy_set_header Upgrade $http_upgrade;
@@ -485,10 +541,167 @@ const TUNNEL_PROXY_HEADERS = `
485
541
  */
486
542
  function bodySizeDirective(maxBodySizeMb) {
487
543
  if (maxBodySizeMb === undefined) return '';
544
+ assertBodySizeMb(maxBodySizeMb);
545
+ return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
546
+ }
547
+
548
+ /** A body limit in MiB, as the tunnel workflow allows it — interpolated into nginx configuration. */
549
+ function assertBodySizeMb(maxBodySizeMb) {
488
550
  if (!Number.isInteger(maxBodySizeMb) || maxBodySizeMb < 1 || maxBodySizeMb > 10240) {
489
551
  throw new Error(`Invalid maxBodySizeMb for nginx vhost: ${maxBodySizeMb}`);
490
552
  }
491
- return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
553
+ }
554
+
555
+ /**
556
+ * Request headers a user-surface location sends the panel. The panel's
557
+ * user-surface context trusts nothing but the relay assertion — but the
558
+ * panel serves other contexts on the same listener, so everything a browser
559
+ * could send that any of them trusts is cleared: no user request can pass
560
+ * for a client-certificate holder (`X-SSL-Client-*`), an Authelia user
561
+ * (`Remote-*`), a desktop session (`Authorization`) or an administrator's
562
+ * 2FA session (`Cookie`), nor name another host to the panel
563
+ * (`X-Forwarded-Host`). `X-Forwarded-For` is the client's address alone:
564
+ * the panel trusts one loopback hop (`trust-proxy.js`), so that is its
565
+ * `request.ip` — what the user surface's rate limit keys on. `Connection`
566
+ * cleared: the panel keeps the upstream connection alive (HTTP/1.1), and
567
+ * the user surface carries no WebSocket.
568
+ */
569
+ const USER_SURFACE_PROXY_HEADERS = `
570
+ # Nothing else that any part of the panel trusts
571
+ proxy_set_header Cookie "";
572
+ proxy_set_header Authorization "";
573
+ proxy_set_header Remote-User "";
574
+ proxy_set_header Remote-Groups "";
575
+ proxy_set_header Remote-Name "";
576
+ proxy_set_header Remote-Email "";
577
+ proxy_set_header X-SSL-Client-Verify "";
578
+ proxy_set_header X-SSL-Client-DN "";
579
+ proxy_set_header X-SSL-Client-Serial "";
580
+ proxy_set_header X-Forwarded-Host "";
581
+
582
+ proxy_set_header Host $host;
583
+ proxy_set_header X-Real-IP $remote_addr;
584
+ proxy_set_header X-Forwarded-For $remote_addr;
585
+ proxy_set_header X-Forwarded-Proto $scheme;
586
+ proxy_set_header Connection "";`;
587
+
588
+ /**
589
+ * The body of a tunnel vhost's Gatekeeper subrequest location,
590
+ * `/authz/<check>/<fqdn>`: the tunnel is named in the path, in
591
+ * `X-Original-URL` and in `Host` — never by the client's `Host` header (see
592
+ * {@link originalUrl}) — and the session cookie is passed on.
593
+ *
594
+ * @param {'tunnel'|'user-surface'|'user-surface-shell'} check
595
+ * @param {string} fqdn
596
+ * @returns {string}
597
+ */
598
+ function gatekeeperSubrequest(check, fqdn) {
599
+ return `internal;
600
+ # A subrequest is never authorized itself (a host may authorize at server level)
601
+ auth_request off;
602
+
603
+ proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
604
+ proxy_pass_request_body off;
605
+
606
+ proxy_set_header Content-Length "";
607
+ proxy_set_header Connection "";
608
+ proxy_set_header X-Original-Method $request_method;
609
+ proxy_set_header X-Original-URL ${originalUrl(fqdn)};
610
+ proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
611
+ proxy_set_header Host ${fqdn};
612
+ proxy_set_header Cookie $http_cookie;
613
+
614
+ proxy_http_version 1.1;`;
615
+ }
616
+
617
+ /**
618
+ * The user-surface locations of a tunnel vhost — `/api/<route>/app` and
619
+ * everything under it for each plugin it serves (`opts.userSurface`), and
620
+ * the two internal locations they share:
621
+ *
622
+ * - `/internal/lamaste/user-surface-authz` asks Gatekeeper
623
+ * (`/authz/user-surface/<fqdn>`, the tunnel named in the path and in
624
+ * `X-Original-URL` and `Host`, as for the tunnel's own subrequest).
625
+ * Gatekeeper authenticates the Authelia session — and, on a restricted
626
+ * tunnel, checks its grant — then vouches for the user to the panel with
627
+ * a relay assertion bound to the method and the raw request target. Never
628
+ * cached: an assertion is single use.
629
+ * - `/internal/lamaste/user-surface-401` answers a request without a
630
+ * session with Gatekeeper's JSON 401 — an application's request is never
631
+ * redirected to the login page.
632
+ *
633
+ * Each route's locations pass the assertion, and nothing else of the
634
+ * browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
635
+ * with `proxy_pass` naming no URI: the panel receives the request target
636
+ * exactly as the browser sent it — the target the assertion is bound to.
637
+ * The body limit is the plugin's (its internal tunnel's); the timeouts are
638
+ * the longest a user-surface response lives (`USER_SURFACE_MAX_RESPONSE_SECONDS`,
639
+ * one hour — the panel ends one still open then, and ends it at once when
640
+ * its user loses access), long enough for server-sent events, which the
641
+ * panel streams unbuffered.
642
+ *
643
+ * @param {string} fqdn
644
+ * @param {ReadonlyArray<{ route: string, maxBodySizeMb: number }>} userSurface
645
+ * @returns {string}
646
+ */
647
+ function userSurfaceLocations(fqdn, userSurface) {
648
+ if (userSurface.length === 0) return '';
649
+ const routes = userSurface.map(({ route, maxBodySizeMb }) => {
650
+ if (!isUserSurfaceRoute(route)) {
651
+ throw new Error(`Invalid user-surface route for nginx vhost: ${route}`);
652
+ }
653
+ assertBodySizeMb(maxBodySizeMb);
654
+ const block = `{
655
+ auth_request /internal/lamaste/user-surface-authz;
656
+ auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
657
+
658
+ # The user, vouched for by Gatekeeper to the panel for this one request
659
+ proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
660
+ ${USER_SURFACE_PROXY_HEADERS}
661
+
662
+ # No URI: the request target reaches the panel as the browser sent it
663
+ proxy_pass http://127.0.0.1:${RELAY_PANEL_PORT};
664
+ proxy_http_version 1.1;
665
+
666
+ client_max_body_size ${maxBodySizeMb}m;
667
+ # No response lives longer (the panel ends one then)
668
+ proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
669
+ proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
670
+
671
+ # No session: a JSON 401, never a redirect
672
+ error_page 401 = /internal/lamaste/user-surface-401;
673
+ }`;
674
+ const apiPath = userSurfaceApiPath(route);
675
+ return `
676
+ # The user surface of plugin "${route}"
677
+ location = ${apiPath} ${block}
678
+
679
+ location ^~ ${apiPath}/ ${block}
680
+ `;
681
+ });
682
+ return `
683
+ # The user surface of agent-hosted plugins on this host: Gatekeeper
684
+ # vouches for the session's user to the panel, for one request each.
685
+ location = /internal/lamaste/user-surface-authz {
686
+ ${gatekeeperSubrequest('user-surface', fqdn)}
687
+ }
688
+
689
+ location = /internal/lamaste/user-surface-401 {
690
+ internal;
691
+ auth_request off;
692
+
693
+ proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
694
+ proxy_pass_request_body off;
695
+
696
+ proxy_set_header Content-Length "";
697
+ proxy_set_header Cookie "";
698
+ proxy_set_header Authorization "";
699
+ # Gatekeeper's rate limit counts the client nginx saw, never one a client named
700
+ proxy_set_header X-Forwarded-For $remote_addr;
701
+
702
+ proxy_http_version 1.1;
703
+ }
704
+ ${routes.join('')}`;
492
705
  }
493
706
 
494
707
  /**
@@ -500,19 +713,30 @@ export async function writePublicVhost(
500
713
  domain,
501
714
  port,
502
715
  certPath,
503
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
716
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
504
717
  ) {
505
718
  const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
506
719
  pathPrefix,
507
720
  maxBodySizeMb,
721
+ userSurface,
508
722
  });
509
723
  return safeWriteVhost(name, config, fqdn, { enabled });
510
724
  }
511
725
 
512
- function renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb } = {}) {
726
+ function renderPublicVhost(
727
+ subdomain,
728
+ domain,
729
+ port,
730
+ certPath,
731
+ { pathPrefix, maxBodySizeMb, userSurface } = {},
732
+ ) {
513
733
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
514
734
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
515
735
  }
736
+ // A public host has no signed-in user for Gatekeeper to vouch for.
737
+ if (userSurface && userSurface.length > 0) {
738
+ throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
739
+ }
516
740
 
517
741
  const fqdn = `${subdomain}.${domain}`;
518
742
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
@@ -536,14 +760,11 @@ ${bodySizeDirective(maxBodySizeMb)}
536
760
  proxy_set_header Remote-User "";
537
761
  proxy_set_header Remote-Groups "";
538
762
  proxy_set_header Remote-Name "";
539
- proxy_set_header Remote-Email "";${
540
- pathPrefix
541
- ? `
542
- # Nor a relay assertion: lamaste-agentd, which serves plugin routes,
543
- # would otherwise be handed one a client sent.
544
- proxy_set_header X-Lamalibre-Relay-Assertion "";`
545
- : ''
546
- }
763
+ proxy_set_header Remote-Email "";
764
+
765
+ # Nor a relay assertion: only Gatekeeper's, set on an authenticated
766
+ # vhost, may reach a backend (lamaste-agentd serves plugin routes)
767
+ proxy_set_header X-Lamalibre-Relay-Assertion "";
547
768
  ${TUNNEL_PROXY_HEADERS}
548
769
 
549
770
  ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
@@ -568,11 +789,12 @@ export async function writeAuthenticatedVhost(
568
789
  domain,
569
790
  port,
570
791
  certPath,
571
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
792
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
572
793
  ) {
573
794
  const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
574
795
  pathPrefix,
575
796
  maxBodySizeMb,
797
+ userSurface,
576
798
  restricted: false,
577
799
  });
578
800
  return safeWriteVhost(name, config, fqdn, { enabled });
@@ -588,11 +810,12 @@ export async function writeRestrictedVhost(
588
810
  domain,
589
811
  port,
590
812
  certPath,
591
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
813
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
592
814
  ) {
593
815
  const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
594
816
  pathPrefix,
595
817
  maxBodySizeMb,
818
+ userSurface,
596
819
  restricted: true,
597
820
  });
598
821
  return safeWriteVhost(name, config, fqdn, { enabled });
@@ -603,7 +826,7 @@ function renderGatekeeperTunnelVhost(
603
826
  domain,
604
827
  port,
605
828
  certPath,
606
- { pathPrefix, maxBodySizeMb, restricted },
829
+ { pathPrefix, maxBodySizeMb, userSurface, restricted },
607
830
  ) {
608
831
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
609
832
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
@@ -617,6 +840,7 @@ function renderGatekeeperTunnelVhost(
617
840
  const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
618
841
  pathPrefix,
619
842
  maxBodySizeMb,
843
+ userSurface,
620
844
  restricted,
621
845
  });
622
846
 
@@ -629,16 +853,27 @@ function renderGatekeeperTunnelVhost(
629
853
  * the generators produce today (see vhost-reconcile.js).
630
854
  *
631
855
  * @param {'public'|'authenticated'|'restricted'} accessMode
856
+ * @param {string} subdomain
857
+ * @param {string} domain
858
+ * @param {number} port
859
+ * @param {string} [certPath]
860
+ * @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
861
+ * `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
632
862
  * @returns {{ name: string, fqdn: string, config: string }}
633
863
  */
634
864
  export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
635
- const { pathPrefix, maxBodySizeMb } = opts;
865
+ const { pathPrefix, maxBodySizeMb, userSurface } = opts;
636
866
  if (accessMode === 'public') {
637
- return renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb });
867
+ return renderPublicVhost(subdomain, domain, port, certPath, {
868
+ pathPrefix,
869
+ maxBodySizeMb,
870
+ userSurface,
871
+ });
638
872
  }
639
873
  return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
640
874
  pathPrefix,
641
875
  maxBodySizeMb,
876
+ userSurface,
642
877
  restricted: accessMode !== 'authenticated',
643
878
  });
644
879
  }
@@ -658,13 +893,18 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
658
893
  * the one Gatekeeper authorized. Gatekeeper now refuses `/authz/check`: a
659
894
  * vhost the panel has not rewritten closes its tunnel instead of opening it.
660
895
  * The login redirect names this vhost for the same reason.
896
+ *
897
+ * The backend gets Gatekeeper's relay assertion on a plugin tunnel, and no
898
+ * relay assertion at all on any other — a client's is cleared. An app
899
+ * tunnel bound as plugins' user surface also carries their locations
900
+ * ({@link userSurfaceLocations}).
661
901
  */
662
902
  function buildGatekeeperVhost(
663
903
  fqdn,
664
904
  certDirClean,
665
905
  port,
666
906
  domain,
667
- { pathPrefix, maxBodySizeMb, restricted } = {},
907
+ { pathPrefix, maxBodySizeMb, userSurface = [], restricted } = {},
668
908
  ) {
669
909
  const url = originalUrl(fqdn);
670
910
  return `server {
@@ -681,24 +921,11 @@ ${bodySizeDirective(maxBodySizeMb)}
681
921
  # Gatekeeper authorization subrequest (handles Authelia validation + grant check).
682
922
  # The tunnel is named in the path — never by the client's Host header.
683
923
  location /internal/lamaste/authz {
684
- internal;
685
-
686
- proxy_pass http://127.0.0.1:9294/authz/tunnel/${fqdn};
687
- proxy_pass_request_body off;
688
-
689
- proxy_set_header Content-Length "";
690
- proxy_set_header Connection "";
691
- proxy_set_header X-Original-Method $request_method;
692
- proxy_set_header X-Original-URL ${url};
693
- proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
694
- proxy_set_header Host ${fqdn};
695
- proxy_set_header Cookie $http_cookie;
696
-
697
- proxy_http_version 1.1;
924
+ ${gatekeeperSubrequest('tunnel', fqdn)}
698
925
  proxy_buffers 4 32k;
699
926
  proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
700
927
  }
701
-
928
+ ${userSurfaceLocations(fqdn, userSurface)}
702
929
  location / {
703
930
  auth_request /internal/lamaste/authz;
704
931
  auth_request_set $user $upstream_http_remote_user;
@@ -721,7 +948,11 @@ ${bodySizeDirective(maxBodySizeMb)}
721
948
  # The user, signed by Gatekeeper for this one request — lamaste-agentd,
722
949
  # which serves plugin routes, trusts no Remote-* header without it
723
950
  proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;`
724
- : ''
951
+ : `
952
+
953
+ # No relay assertion reaches an app: Gatekeeper signs one only for a
954
+ # plugin tunnel or the panel, never one a client sent
955
+ proxy_set_header X-Lamalibre-Relay-Assertion "";`
725
956
  }
726
957
  ${TUNNEL_PROXY_HEADERS}
727
958
 
@@ -936,6 +1167,269 @@ ${aliasBlock}`;
936
1167
  return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
937
1168
  }
938
1169
 
1170
+ /**
1171
+ * The Content-Security-Policy of a plugin's web surface: its shell loads
1172
+ * scripts, styles, fonts and images from its own host only and talks to its
1173
+ * own host only (its user surface's API is there). Inline styles are
1174
+ * allowed — frameworks set `style` attributes and insert component styles
1175
+ * at run time — inline scripts never; no framing, no plugins, no foreign
1176
+ * form targets.
1177
+ */
1178
+ const WEB_SURFACE_CSP =
1179
+ "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'";
1180
+
1181
+ /**
1182
+ * Response headers of the shell of a plugin's web surface, set in its
1183
+ * locations — not at server level, where the API's answers (headed by the
1184
+ * panel) would get a second copy. `no-cache`: a browser revalidates each file —
1185
+ * a 304 while the tree is the same, the new file once an update switched
1186
+ * it — so no page mixes two versions of the shell from its cache.
1187
+ */
1188
+ const WEB_SURFACE_HEADERS = `
1189
+ add_header Content-Security-Policy "${WEB_SURFACE_CSP}" always;
1190
+ add_header X-Content-Type-Options nosniff always;
1191
+ add_header X-Frame-Options DENY always;
1192
+ add_header Referrer-Policy same-origin always;
1193
+ add_header Cross-Origin-Opener-Policy same-origin always;
1194
+ add_header Cross-Origin-Resource-Policy same-origin always;
1195
+ add_header Cache-Control no-cache always;`;
1196
+
1197
+ /**
1198
+ * The content types of a web tree's files, by extension
1199
+ * (`WEB_TREE_CONTENT_TYPES` — the only extensions a tree may hold).
1200
+ */
1201
+ const WEB_SURFACE_TYPES = (() => {
1202
+ const byType = new Map();
1203
+ for (const [ext, type] of Object.entries(WEB_TREE_CONTENT_TYPES)) {
1204
+ byType.set(type, [...(byType.get(type) ?? []), ext.slice(1)]);
1205
+ }
1206
+ const entries = [...byType].map(([type, exts]) => ` ${type} ${exts.join(' ')};`);
1207
+ return `
1208
+ types {
1209
+ ${entries.join('\n')}
1210
+ }`;
1211
+ })();
1212
+
1213
+ /**
1214
+ * The files a page still running the previous shell may ask for after an
1215
+ * update — its scripts, styles, fonts and images — and nothing else: a
1216
+ * document, an SVG or a manifest is never served from the previous tree.
1217
+ */
1218
+ const WEB_SURFACE_SUBRESOURCE_RE = '[.](?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|ico)$';
1219
+
1220
+ /**
1221
+ * Service workers: never registered from a plugin's site. A worker outlives
1222
+ * the tree that installed it — a revert, the site's deletion, the
1223
+ * hostname's next owner — and answers in its place; asked for its script
1224
+ * (`Service-Worker: script`), the site answers 404, which also
1225
+ * unregisters a worker already installed at its next update check.
1226
+ */
1227
+ const NO_SERVICE_WORKER = `
1228
+ if ($http_service_worker) {
1229
+ return 404;
1230
+ }`;
1231
+
1232
+ /**
1233
+ * Render the vhost of a plugin's web surface — a site the plugin owns
1234
+ * (`site.owner`). Pure; the startup reconcile renders it again to compare.
1235
+ *
1236
+ * While the plugin's surface is served (`opts.served`: its installed
1237
+ * version approved, declaring identity and a web surface, hosted), the
1238
+ * host serves two things under one Authelia session:
1239
+ *
1240
+ * - **the shell**: every path but the API, from the plugin's current tree
1241
+ * (`<treesDir>/current`), GET and HEAD only, each file under its own
1242
+ * content type (`WEB_SURFACE_TYPES`), never a service worker's script. Every request asks
1243
+ * Gatekeeper first (`/authz/user-surface-shell/<fqdn>`: a session in the
1244
+ * plugin's audience, and the site's grant when it is restricted) — a page
1245
+ * without a session goes to the login, a user without access gets the
1246
+ * access-request page. A script, style, font or image the current tree
1247
+ * lacks is looked for in the previous tree (a page still running the old
1248
+ * shell loads its lazy files after an update), else 404; any other
1249
+ * unknown path gets the current shell's `index.html`.
1250
+ * - **the API**: `/api/<route>/app/*`, exactly as on a bound tunnel
1251
+ * ({@link userSurfaceLocations}).
1252
+ *
1253
+ * While it is not served the host answers every request with 503 — and,
1254
+ * once its plugin is no longer installed (`opts.clearSiteData`), tells
1255
+ * browsers to clear the site's storage.
1256
+ *
1257
+ * @param {{ id: string, fqdn: string }} site
1258
+ * @param {string} certDir
1259
+ * @param {string} domain
1260
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string, clearSiteData?: boolean }} opts
1261
+ * @returns {{ name: string, fqdn: string, config: string }}
1262
+ */
1263
+ export function renderWebSurfaceVhost(site, certDir, domain, opts) {
1264
+ const { route, served, maxBodySizeMb, treesDir, clearSiteData = false } = opts;
1265
+ assertLiteralHostname(site.fqdn);
1266
+ if (!isUserSurfaceRoute(route)) throw new Error(`Invalid web surface route: ${route}`);
1267
+ if (!path.isAbsolute(treesDir) || !/^[A-Za-z0-9/._-]+$/.test(treesDir)) {
1268
+ throw new Error(`Invalid web tree directory for nginx vhost: ${treesDir}`);
1269
+ }
1270
+ let certDirClean = certDir;
1271
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
1272
+
1273
+ const head = `server {
1274
+ listen 443 ssl;
1275
+ server_name ${site.fqdn};
1276
+
1277
+ ssl_certificate ${certDirClean}/fullchain.pem;
1278
+ ssl_certificate_key ${certDirClean}/privkey.pem;
1279
+
1280
+ ssl_protocols TLSv1.2 TLSv1.3;
1281
+ ssl_ciphers HIGH:!aNULL:!MD5;
1282
+ ssl_prefer_server_ciphers on;
1283
+ `;
1284
+
1285
+ if (!served) {
1286
+ // A plugin no longer installed: whatever comes next on this hostname is
1287
+ // not its shell, so browsers drop what it left — its storage, caches
1288
+ // and service workers. Never while the plugin is only unhosted or
1289
+ // awaiting approval: its users' data stays.
1290
+ const clear = clearSiteData
1291
+ ? `
1292
+ add_header Clear-Site-Data '"storage"' always;`
1293
+ : '';
1294
+ return {
1295
+ name: `lamalibre-lamaste-site-${site.id}`,
1296
+ fqdn: site.fqdn,
1297
+ config: `${head}
1298
+ # The web surface of plugin "${route}" is not served now (not installed,
1299
+ # not approved, not hosted, or its version declares no web surface)
1300
+ location / {${NO_SERVICE_WORKER}
1301
+ add_header Cache-Control no-store always;${clear}
1302
+ return 503;
1303
+ }
1304
+ }
1305
+ `,
1306
+ };
1307
+ }
1308
+
1309
+ const url = originalUrl(site.fqdn);
1310
+ const config = `${head}
1311
+ root ${treesDir}/current;
1312
+ index index.html;
1313
+
1314
+ # Every path of the shell: Gatekeeper first (the API's locations ask on their own)
1315
+ auth_request /internal/lamaste/shell-authz;
1316
+ error_page 401 =302 https://auth.${domain}/?rd=${url};
1317
+ error_page 403 = /internal/lamaste/shell-authz;
1318
+
1319
+ location = /internal/lamaste/shell-authz {
1320
+ ${gatekeeperSubrequest('user-surface-shell', site.fqdn)}
1321
+ }
1322
+ ${userSurfaceLocations(site.fqdn, [{ route, maxBodySizeMb }])}
1323
+ # A script, style, font or image the current tree lacks: the previous
1324
+ # tree's (a page still running the old shell), else 404
1325
+ location ~* ${WEB_SURFACE_SUBRESOURCE_RE} {
1326
+ if ($request_method !~ ^(GET|HEAD)$) {
1327
+ return 405;
1328
+ }${NO_SERVICE_WORKER}
1329
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1330
+ try_files $uri @previous;
1331
+ }
1332
+
1333
+ # Everything else: the current tree's file, else the shell
1334
+ location / {
1335
+ if ($request_method !~ ^(GET|HEAD)$) {
1336
+ return 405;
1337
+ }${NO_SERVICE_WORKER}
1338
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1339
+ try_files $uri /index.html;
1340
+ }
1341
+
1342
+ location @previous {
1343
+ ${WEB_SURFACE_HEADERS}${WEB_SURFACE_TYPES}
1344
+ root ${treesDir}/previous;
1345
+ try_files $uri =404;
1346
+ }
1347
+ }
1348
+ `;
1349
+ return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
1350
+ }
1351
+
1352
+ /**
1353
+ * Write the vhost of a plugin's web surface (see {@link renderWebSurfaceVhost}).
1354
+ *
1355
+ * @param {{ id: string, fqdn: string }} site
1356
+ * @param {string} certDir
1357
+ * @param {string} domain
1358
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1359
+ */
1360
+ export async function writeWebSurfaceVhost(site, certDir, domain, opts) {
1361
+ const { name, fqdn, config } = renderWebSurfaceVhost(site, certDir, domain, opts);
1362
+ return safeWriteVhost(name, config, fqdn, { enabled: true });
1363
+ }
1364
+
1365
+ /**
1366
+ * Hand a hostname from one vhost to another in one nginx reload: vhost
1367
+ * `fromName` is unlinked, `name` written with `config` and linked, the
1368
+ * configuration tested and nginx reloaded once; `fromName` stays installed,
1369
+ * unlinked. Until the reload nginx serves the old vhost, after it the new
1370
+ * one; there is no moment in between. On any failure before the reload
1371
+ * both are put back as they were.
1372
+ *
1373
+ * @param {string} fromName the vhost giving the hostname up (linked)
1374
+ * @param {string} name a vhost that does not exist yet
1375
+ * @param {string} config
1376
+ * @param {string} fqdn
1377
+ */
1378
+ async function handOverVhost(fromName, name, config, fqdn) {
1379
+ if (await siteExists(name)) throw new Error(`Vhost ${name} exists already`);
1380
+ const fromEnabled = await isSiteEnabled(fromName);
1381
+ const restore = async () => {
1382
+ await siteAction('remove', name).catch(() => {});
1383
+ if (fromEnabled) await enableSite(fromName).catch(() => {});
1384
+ };
1385
+ try {
1386
+ await disableSite(fromName);
1387
+ await writeVhostFile(name, config);
1388
+ await enableSite(name);
1389
+ const result = await testConfig();
1390
+ if (!result.valid) {
1391
+ throw new Error(`Nginx config test failed handing ${fqdn} to ${name}: ${result.error}`);
1392
+ }
1393
+ await reload();
1394
+ } catch (err) {
1395
+ await restore();
1396
+ throw err;
1397
+ }
1398
+ // `fromName` stays installed, only unlinked: the tunnel it served can be
1399
+ // enabled again by hand once the new vhost is gone.
1400
+ }
1401
+
1402
+ /**
1403
+ * Unlink tunnel vhost `subdomain` when it is still enabled although a
1404
+ * plugin's site holds its hostname — a move the panel did not finish (it
1405
+ * wrote the state, then stopped before the handover). Does not reload:
1406
+ * the caller's next vhost install does, or it reloads itself. Returns
1407
+ * whether it unlinked.
1408
+ *
1409
+ * @param {string} subdomain the moved tunnel's
1410
+ */
1411
+ export async function unlinkMovedAppVhost(subdomain) {
1412
+ const name = `lamalibre-lamaste-app-${subdomain}`;
1413
+ if (!(await isSiteEnabled(name))) return false;
1414
+ await disableSite(name);
1415
+ return true;
1416
+ }
1417
+
1418
+ /**
1419
+ * Serve a plugin's web surface on the hostname of tunnel vhost `subdomain`
1420
+ * in one reload (see {@link handOverVhost}).
1421
+ *
1422
+ * @param {string} subdomain the tunnel's
1423
+ * @param {{ id: string, fqdn: string }} site
1424
+ * @param {string} certDir
1425
+ * @param {string} domain
1426
+ * @param {{ route: string, served: boolean, maxBodySizeMb: number, treesDir: string }} opts
1427
+ */
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);
1431
+ }
1432
+
939
1433
  /**
940
1434
  * Server block answering a site's aliases with a permanent redirect to the
941
1435
  * primary hostname, on the same certificate (its lineage covers every alias).
@@ -1148,7 +1642,9 @@ export async function writeAgentPanelVhost(
1148
1642
  * reached without going through nginx, so a header proves nothing to it.
1149
1643
  *
1150
1644
  * `X-SSL-Client-*` still go upstream for agents older than 4.3, which read
1151
- * them; lamaste-agentd from 4.3 removes them unread.
1645
+ * them; lamaste-agentd from 4.3 removes them unread. Authelia's session
1646
+ * cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
1647
+ * is under the base domain like every other.
1152
1648
  *
1153
1649
  * @param {string} subdomain - `agent-<label>`
1154
1650
  * @param {string} domain
@@ -1208,6 +1704,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1208
1704
  proxy_set_header Remote-Groups "";
1209
1705
  proxy_set_header Remote-Name "";
1210
1706
  proxy_set_header Remote-Email "";
1707
+ proxy_set_header X-Lamalibre-Relay-Assertion "";
1211
1708
  }
1212
1709
 
1213
1710
  # Reject requests without valid client certificate
@@ -1236,6 +1733,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1236
1733
  proxy_set_header Remote-Groups "";
1237
1734
  proxy_set_header Remote-Name "";
1238
1735
  proxy_set_header Remote-Email "";
1736
+ ${BACKEND_COOKIE_HEADER}
1239
1737
 
1240
1738
  # Standard proxy headers
1241
1739
  proxy_set_header Host $host;