@lamalibre/create-lamaste 4.4.1 → 4.5.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/lamaste-priv +200 -16
  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/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +291 -22
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +82 -188
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugins.js +59 -4
  125. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  126. package/vendor/serverd/src/lib/request-logging.js +19 -0
  127. package/vendor/serverd/src/lib/retained-data.js +50 -4
  128. package/vendor/serverd/src/lib/state.js +18 -0
  129. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  130. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  131. package/vendor/serverd/src/lib/user-caller.js +67 -0
  132. package/vendor/serverd/src/lib/user-surface.js +733 -0
  133. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  134. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  135. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  136. package/vendor/serverd/src/routes/invite.js +53 -12
  137. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  138. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  139. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  140. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  141. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  142. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  143. package/vendor/serverd/src/routes/management/users.js +290 -208
  144. package/vendor/serverd/src/routes/management.js +4 -0
  145. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  146. package/vendor/serverd/src/routes/user-access.js +17 -4
  147. package/vendor/serverd/src/routes/user-surface.js +69 -0
  148. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  149. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -1,6 +1,13 @@
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
+ } from '@lamalibre/lamaste';
4
11
 
5
12
  const SITES_AVAILABLE = '/etc/nginx/sites-available';
6
13
  const SITES_ENABLED = '/etc/nginx/sites-enabled';
@@ -58,11 +65,41 @@ async function writeVhostFile(name, content) {
58
65
  await siteAction('write', name, content);
59
66
  }
60
67
 
68
+ /** Site name of the panel's domain vhost (`panel.<domain>`). */
69
+ export const PANEL_DOMAIN_VHOST = 'lamalibre-lamaste-panel-domain';
70
+
71
+ /**
72
+ * Cleared in every location that proxies a browser's request to the panel
73
+ * itself: a relay assertion reaches the panel only from a user-surface
74
+ * location of a tunnel vhost, which sets it from Gatekeeper's answer — never
75
+ * one a client sent.
76
+ */
77
+ const PANEL_ASSERTION_CLEARED = `
78
+ # A relay assertion comes only from Gatekeeper, on a plugin's user surface
79
+ proxy_set_header X-Lamalibre-Relay-Assertion "";`;
80
+
61
81
  /**
62
82
  * Write the panel domain vhost with mTLS and Let's Encrypt certs.
63
83
  */
64
84
  export async function writePanelVhost(domain) {
85
+ const { name, config } = renderPanelVhost(domain);
86
+ await writeVhostFile(name, config);
87
+ return path.join(SITES_AVAILABLE, name);
88
+ }
89
+
90
+ /**
91
+ * Render the panel domain vhost. Pure; the startup reconcile renders it on
92
+ * the certificate directory the installed vhost names.
93
+ *
94
+ * @param {string} domain
95
+ * @param {string} [certPath] - certificate directory (default: the panel's own lineage)
96
+ * @returns {{ name: string, fqdn: string, config: string }}
97
+ */
98
+ export function renderPanelVhost(domain, certPath) {
65
99
  const fqdn = `panel.${domain}`;
100
+ assertLiteralHostname(fqdn);
101
+ let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
102
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
66
103
  const config = `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
67
104
  limit_req_zone $binary_remote_addr zone=enroll_domain:1m rate=5r/m;
68
105
 
@@ -76,8 +113,8 @@ server {
76
113
  listen 443 ssl;
77
114
  server_name ${fqdn};
78
115
 
79
- ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
80
- ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
116
+ ssl_certificate ${certDirClean}/fullchain.pem;
117
+ ssl_certificate_key ${certDirClean}/privkey.pem;
81
118
 
82
119
  # mTLS — same as IP-based access
83
120
  include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
@@ -103,7 +140,7 @@ server {
103
140
  proxy_set_header Remote-User "";
104
141
  proxy_set_header Remote-Groups "";
105
142
  proxy_set_header Remote-Name "";
106
- proxy_set_header Remote-Email "";
143
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
107
144
 
108
145
  # Standard proxy headers
109
146
  proxy_set_header Host $host;
@@ -126,7 +163,7 @@ server {
126
163
  proxy_set_header Remote-User "";
127
164
  proxy_set_header Remote-Groups "";
128
165
  proxy_set_header Remote-Name "";
129
- proxy_set_header Remote-Email "";
166
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
130
167
 
131
168
  proxy_set_header Host $host;
132
169
  proxy_set_header X-Real-IP $remote_addr;
@@ -146,7 +183,7 @@ server {
146
183
  proxy_set_header Remote-User "";
147
184
  proxy_set_header Remote-Groups "";
148
185
  proxy_set_header Remote-Name "";
149
- proxy_set_header Remote-Email "";
186
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
150
187
 
151
188
  proxy_set_header Host $host;
152
189
  proxy_set_header X-Real-IP $remote_addr;
@@ -167,7 +204,7 @@ server {
167
204
  proxy_set_header Remote-User "";
168
205
  proxy_set_header Remote-Groups "";
169
206
  proxy_set_header Remote-Name "";
170
- proxy_set_header Remote-Email "";
207
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
171
208
 
172
209
  proxy_set_header Host $host;
173
210
  proxy_set_header X-Real-IP $remote_addr;
@@ -187,7 +224,7 @@ server {
187
224
  proxy_set_header Remote-User "";
188
225
  proxy_set_header Remote-Groups "";
189
226
  proxy_set_header Remote-Name "";
190
- proxy_set_header Remote-Email "";
227
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
191
228
 
192
229
  proxy_set_header Host $host;
193
230
  proxy_set_header X-Real-IP $remote_addr;
@@ -207,7 +244,7 @@ server {
207
244
  proxy_set_header Remote-User "";
208
245
  proxy_set_header Remote-Groups "";
209
246
  proxy_set_header Remote-Name "";
210
- proxy_set_header Remote-Email "";
247
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
211
248
 
212
249
  proxy_set_header Host $host;
213
250
  proxy_set_header X-Real-IP $remote_addr;
@@ -232,7 +269,7 @@ server {
232
269
  proxy_set_header Remote-User "";
233
270
  proxy_set_header Remote-Groups "";
234
271
  proxy_set_header Remote-Name "";
235
- proxy_set_header Remote-Email "";
272
+ proxy_set_header Remote-Email "";${PANEL_ASSERTION_CLEARED}
236
273
 
237
274
  # Standard proxy headers
238
275
  proxy_set_header Host $host;
@@ -247,8 +284,7 @@ server {
247
284
  }
248
285
  `;
249
286
 
250
- await writeVhostFile('lamalibre-lamaste-panel-domain', config);
251
- return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
287
+ return { name: PANEL_DOMAIN_VHOST, fqdn, config };
252
288
  }
253
289
 
254
290
  /**
@@ -351,7 +387,7 @@ server {
351
387
  proxy_set_header Host $host;
352
388
  proxy_set_header X-Real-IP $remote_addr;
353
389
  proxy_set_header X-Forwarded-For $remote_addr;
354
- proxy_set_header X-Forwarded-Proto $scheme;
390
+ proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
355
391
  }
356
392
 
357
393
  # User access authorization — Authelia forward auth protects this endpoint
@@ -393,7 +429,7 @@ server {
393
429
  proxy_set_header Host $host;
394
430
  proxy_set_header X-Real-IP $remote_addr;
395
431
  proxy_set_header X-Forwarded-For $remote_addr;
396
- proxy_set_header X-Forwarded-Proto $scheme;
432
+ proxy_set_header X-Forwarded-Proto $scheme;${PANEL_ASSERTION_CLEARED}
397
433
 
398
434
  error_page 401 =302 $redirection_url;
399
435
  }
@@ -446,6 +482,23 @@ export async function writeTunnelVhost(domain) {
446
482
  return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-tunnel');
447
483
  }
448
484
 
485
+ /**
486
+ * The `Cookie` a tunnel or agent backend receives: the browser's cookies
487
+ * without Authelia's session cookie. Authelia scopes that cookie to the base
488
+ * domain, so the browser sends it to every tunnel host; a backend that got
489
+ * it — any application on any agent, or whatever holds a tunnel port on the
490
+ * relay — could replay it as the visitor on every authenticated host and
491
+ * every plugin's user surface. `$lamaste_backend_cookie` is defined by the
492
+ * http-level map create-lamaste installs (core `renderBackendCookieMap`,
493
+ * `/etc/nginx/conf.d/lamalibre-lamaste-backend-cookie.conf`): every
494
+ * `authelia_session` pair removed, or no `Cookie` at all when one could not
495
+ * be. A vhost written before the map exists fails `nginx -t` and is rolled
496
+ * back — never served with the cookie.
497
+ */
498
+ const BACKEND_COOKIE_HEADER = `
499
+ # Never Authelia's session cookie: the browser's other cookies only
500
+ proxy_set_header Cookie ${BACKEND_COOKIE_VARIABLE};`;
501
+
449
502
  /**
450
503
  * Request headers every tunnel location sends upstream.
451
504
  *
@@ -457,7 +510,8 @@ export async function writeTunnelVhost(domain) {
457
510
  * The client-certificate headers are cleared. The panel trusts
458
511
  * `X-SSL-Client-*` from nginx as proof of an mTLS client certificate; a
459
512
  * tunnel vhost must never pass client-supplied values through, whatever
460
- * listens on its port.
513
+ * listens on its port. Authelia's session cookie never reaches the backend
514
+ * ({@link BACKEND_COOKIE_HEADER}).
461
515
  */
462
516
  const TUNNEL_PROXY_HEADERS = `
463
517
  proxy_set_header Host $host;
@@ -469,6 +523,7 @@ const TUNNEL_PROXY_HEADERS = `
469
523
  proxy_set_header X-SSL-Client-Verify "";
470
524
  proxy_set_header X-SSL-Client-DN "";
471
525
  proxy_set_header X-SSL-Client-Serial "";
526
+ ${BACKEND_COOKIE_HEADER}
472
527
 
473
528
  # WebSocket support
474
529
  proxy_set_header Upgrade $http_upgrade;
@@ -485,10 +540,164 @@ const TUNNEL_PROXY_HEADERS = `
485
540
  */
486
541
  function bodySizeDirective(maxBodySizeMb) {
487
542
  if (maxBodySizeMb === undefined) return '';
543
+ assertBodySizeMb(maxBodySizeMb);
544
+ return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
545
+ }
546
+
547
+ /** A body limit in MiB, as the tunnel workflow allows it — interpolated into nginx configuration. */
548
+ function assertBodySizeMb(maxBodySizeMb) {
488
549
  if (!Number.isInteger(maxBodySizeMb) || maxBodySizeMb < 1 || maxBodySizeMb > 10240) {
489
550
  throw new Error(`Invalid maxBodySizeMb for nginx vhost: ${maxBodySizeMb}`);
490
551
  }
491
- return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
552
+ }
553
+
554
+ /**
555
+ * Request headers a user-surface location sends the panel. The panel's
556
+ * user-surface context trusts nothing but the relay assertion — but the
557
+ * panel serves other contexts on the same listener, so everything a browser
558
+ * could send that any of them trusts is cleared: no user request can pass
559
+ * for a client-certificate holder (`X-SSL-Client-*`), an Authelia user
560
+ * (`Remote-*`), a desktop session (`Authorization`) or an administrator's
561
+ * 2FA session (`Cookie`), nor name another host to the panel
562
+ * (`X-Forwarded-Host`). `X-Forwarded-For` is the client's address alone:
563
+ * the panel trusts one loopback hop (`trust-proxy.js`), so that is its
564
+ * `request.ip` — what the user surface's rate limit keys on. `Connection`
565
+ * cleared: the panel keeps the upstream connection alive (HTTP/1.1), and
566
+ * the user surface carries no WebSocket.
567
+ */
568
+ const USER_SURFACE_PROXY_HEADERS = `
569
+ # Nothing else that any part of the panel trusts
570
+ proxy_set_header Cookie "";
571
+ proxy_set_header Authorization "";
572
+ proxy_set_header Remote-User "";
573
+ proxy_set_header Remote-Groups "";
574
+ proxy_set_header Remote-Name "";
575
+ proxy_set_header Remote-Email "";
576
+ proxy_set_header X-SSL-Client-Verify "";
577
+ proxy_set_header X-SSL-Client-DN "";
578
+ proxy_set_header X-SSL-Client-Serial "";
579
+ proxy_set_header X-Forwarded-Host "";
580
+
581
+ proxy_set_header Host $host;
582
+ proxy_set_header X-Real-IP $remote_addr;
583
+ proxy_set_header X-Forwarded-For $remote_addr;
584
+ proxy_set_header X-Forwarded-Proto $scheme;
585
+ proxy_set_header Connection "";`;
586
+
587
+ /**
588
+ * The body of a tunnel vhost's Gatekeeper subrequest location,
589
+ * `/authz/<check>/<fqdn>`: the tunnel is named in the path, in
590
+ * `X-Original-URL` and in `Host` — never by the client's `Host` header (see
591
+ * {@link originalUrl}) — and the session cookie is passed on.
592
+ *
593
+ * @param {'tunnel'|'user-surface'} check
594
+ * @param {string} fqdn
595
+ * @returns {string}
596
+ */
597
+ function gatekeeperSubrequest(check, fqdn) {
598
+ return `internal;
599
+
600
+ proxy_pass http://127.0.0.1:9294/authz/${check}/${fqdn};
601
+ proxy_pass_request_body off;
602
+
603
+ proxy_set_header Content-Length "";
604
+ proxy_set_header Connection "";
605
+ proxy_set_header X-Original-Method $request_method;
606
+ proxy_set_header X-Original-URL ${originalUrl(fqdn)};
607
+ proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
608
+ proxy_set_header Host ${fqdn};
609
+ proxy_set_header Cookie $http_cookie;
610
+
611
+ proxy_http_version 1.1;`;
612
+ }
613
+
614
+ /**
615
+ * The user-surface locations of a tunnel vhost — `/api/<route>/app` and
616
+ * everything under it for each plugin it serves (`opts.userSurface`), and
617
+ * the two internal locations they share:
618
+ *
619
+ * - `/internal/lamaste/user-surface-authz` asks Gatekeeper
620
+ * (`/authz/user-surface/<fqdn>`, the tunnel named in the path and in
621
+ * `X-Original-URL` and `Host`, as for the tunnel's own subrequest).
622
+ * Gatekeeper authenticates the Authelia session — and, on a restricted
623
+ * tunnel, checks its grant — then vouches for the user to the panel with
624
+ * a relay assertion bound to the method and the raw request target. Never
625
+ * cached: an assertion is single use.
626
+ * - `/internal/lamaste/user-surface-401` answers a request without a
627
+ * session with Gatekeeper's JSON 401 — an application's request is never
628
+ * redirected to the login page.
629
+ *
630
+ * Each route's locations pass the assertion, and nothing else of the
631
+ * browser's credentials ({@link USER_SURFACE_PROXY_HEADERS}), to the panel
632
+ * with `proxy_pass` naming no URI: the panel receives the request target
633
+ * exactly as the browser sent it — the target the assertion is bound to.
634
+ * The body limit is the plugin's (its internal tunnel's); the timeouts are
635
+ * the longest a user-surface response lives (`USER_SURFACE_MAX_RESPONSE_SECONDS`,
636
+ * one hour — the panel ends one still open then, and ends it at once when
637
+ * its user loses access), long enough for server-sent events, which the
638
+ * panel streams unbuffered.
639
+ *
640
+ * @param {string} fqdn
641
+ * @param {ReadonlyArray<{ route: string, maxBodySizeMb: number }>} userSurface
642
+ * @returns {string}
643
+ */
644
+ function userSurfaceLocations(fqdn, userSurface) {
645
+ if (userSurface.length === 0) return '';
646
+ const routes = userSurface.map(({ route, maxBodySizeMb }) => {
647
+ if (!isUserSurfaceRoute(route)) {
648
+ throw new Error(`Invalid user-surface route for nginx vhost: ${route}`);
649
+ }
650
+ assertBodySizeMb(maxBodySizeMb);
651
+ const block = `{
652
+ auth_request /internal/lamaste/user-surface-authz;
653
+ auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
654
+
655
+ # The user, vouched for by Gatekeeper to the panel for this one request
656
+ proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
657
+ ${USER_SURFACE_PROXY_HEADERS}
658
+
659
+ # No URI: the request target reaches the panel as the browser sent it
660
+ proxy_pass http://127.0.0.1:${RELAY_PANEL_PORT};
661
+ proxy_http_version 1.1;
662
+
663
+ client_max_body_size ${maxBodySizeMb}m;
664
+ # No response lives longer (the panel ends one then)
665
+ proxy_read_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
666
+ proxy_send_timeout ${USER_SURFACE_MAX_RESPONSE_SECONDS}s;
667
+
668
+ # No session: a JSON 401, never a redirect
669
+ error_page 401 = /internal/lamaste/user-surface-401;
670
+ }`;
671
+ const apiPath = userSurfaceApiPath(route);
672
+ return `
673
+ # The user surface of plugin "${route}"
674
+ location = ${apiPath} ${block}
675
+
676
+ location ${apiPath}/ ${block}
677
+ `;
678
+ });
679
+ return `
680
+ # The user surface of agent-hosted plugins on this host: Gatekeeper
681
+ # vouches for the session's user to the panel, for one request each.
682
+ location = /internal/lamaste/user-surface-authz {
683
+ ${gatekeeperSubrequest('user-surface', fqdn)}
684
+ }
685
+
686
+ location = /internal/lamaste/user-surface-401 {
687
+ internal;
688
+
689
+ proxy_pass http://127.0.0.1:9294/authz/user-surface-unauthenticated;
690
+ proxy_pass_request_body off;
691
+
692
+ proxy_set_header Content-Length "";
693
+ proxy_set_header Cookie "";
694
+ proxy_set_header Authorization "";
695
+ # Gatekeeper's rate limit counts the client nginx saw, never one a client named
696
+ proxy_set_header X-Forwarded-For $remote_addr;
697
+
698
+ proxy_http_version 1.1;
699
+ }
700
+ ${routes.join('')}`;
492
701
  }
493
702
 
494
703
  /**
@@ -500,19 +709,30 @@ export async function writePublicVhost(
500
709
  domain,
501
710
  port,
502
711
  certPath,
503
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
712
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
504
713
  ) {
505
714
  const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
506
715
  pathPrefix,
507
716
  maxBodySizeMb,
717
+ userSurface,
508
718
  });
509
719
  return safeWriteVhost(name, config, fqdn, { enabled });
510
720
  }
511
721
 
512
- function renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb } = {}) {
722
+ function renderPublicVhost(
723
+ subdomain,
724
+ domain,
725
+ port,
726
+ certPath,
727
+ { pathPrefix, maxBodySizeMb, userSurface } = {},
728
+ ) {
513
729
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
514
730
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
515
731
  }
732
+ // A public host has no signed-in user for Gatekeeper to vouch for.
733
+ if (userSurface && userSurface.length > 0) {
734
+ throw new Error("A public tunnel vhost cannot serve a plugin's user surface");
735
+ }
516
736
 
517
737
  const fqdn = `${subdomain}.${domain}`;
518
738
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
@@ -536,14 +756,11 @@ ${bodySizeDirective(maxBodySizeMb)}
536
756
  proxy_set_header Remote-User "";
537
757
  proxy_set_header Remote-Groups "";
538
758
  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
- }
759
+ proxy_set_header Remote-Email "";
760
+
761
+ # Nor a relay assertion: only Gatekeeper's, set on an authenticated
762
+ # vhost, may reach a backend (lamaste-agentd serves plugin routes)
763
+ proxy_set_header X-Lamalibre-Relay-Assertion "";
547
764
  ${TUNNEL_PROXY_HEADERS}
548
765
 
549
766
  ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
@@ -568,11 +785,12 @@ export async function writeAuthenticatedVhost(
568
785
  domain,
569
786
  port,
570
787
  certPath,
571
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
788
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
572
789
  ) {
573
790
  const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
574
791
  pathPrefix,
575
792
  maxBodySizeMb,
793
+ userSurface,
576
794
  restricted: false,
577
795
  });
578
796
  return safeWriteVhost(name, config, fqdn, { enabled });
@@ -588,11 +806,12 @@ export async function writeRestrictedVhost(
588
806
  domain,
589
807
  port,
590
808
  certPath,
591
- { pathPrefix, maxBodySizeMb, enabled = true } = {},
809
+ { pathPrefix, maxBodySizeMb, userSurface, enabled = true } = {},
592
810
  ) {
593
811
  const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
594
812
  pathPrefix,
595
813
  maxBodySizeMb,
814
+ userSurface,
596
815
  restricted: true,
597
816
  });
598
817
  return safeWriteVhost(name, config, fqdn, { enabled });
@@ -603,7 +822,7 @@ function renderGatekeeperTunnelVhost(
603
822
  domain,
604
823
  port,
605
824
  certPath,
606
- { pathPrefix, maxBodySizeMb, restricted },
825
+ { pathPrefix, maxBodySizeMb, userSurface, restricted },
607
826
  ) {
608
827
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
609
828
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
@@ -617,6 +836,7 @@ function renderGatekeeperTunnelVhost(
617
836
  const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
618
837
  pathPrefix,
619
838
  maxBodySizeMb,
839
+ userSurface,
620
840
  restricted,
621
841
  });
622
842
 
@@ -629,16 +849,27 @@ function renderGatekeeperTunnelVhost(
629
849
  * the generators produce today (see vhost-reconcile.js).
630
850
  *
631
851
  * @param {'public'|'authenticated'|'restricted'} accessMode
852
+ * @param {string} subdomain
853
+ * @param {string} domain
854
+ * @param {number} port
855
+ * @param {string} [certPath]
856
+ * @param {{ pathPrefix?: string, maxBodySizeMb?: number, userSurface?: ReadonlyArray<{ route: string, maxBodySizeMb: number }> }} [opts]
857
+ * `userSurface`: the plugins' user surfaces the vhost serves (not on a public vhost)
632
858
  * @returns {{ name: string, fqdn: string, config: string }}
633
859
  */
634
860
  export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
635
- const { pathPrefix, maxBodySizeMb } = opts;
861
+ const { pathPrefix, maxBodySizeMb, userSurface } = opts;
636
862
  if (accessMode === 'public') {
637
- return renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb });
863
+ return renderPublicVhost(subdomain, domain, port, certPath, {
864
+ pathPrefix,
865
+ maxBodySizeMb,
866
+ userSurface,
867
+ });
638
868
  }
639
869
  return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
640
870
  pathPrefix,
641
871
  maxBodySizeMb,
872
+ userSurface,
642
873
  restricted: accessMode !== 'authenticated',
643
874
  });
644
875
  }
@@ -658,13 +889,18 @@ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath,
658
889
  * the one Gatekeeper authorized. Gatekeeper now refuses `/authz/check`: a
659
890
  * vhost the panel has not rewritten closes its tunnel instead of opening it.
660
891
  * The login redirect names this vhost for the same reason.
892
+ *
893
+ * The backend gets Gatekeeper's relay assertion on a plugin tunnel, and no
894
+ * relay assertion at all on any other — a client's is cleared. An app
895
+ * tunnel bound as plugins' user surface also carries their locations
896
+ * ({@link userSurfaceLocations}).
661
897
  */
662
898
  function buildGatekeeperVhost(
663
899
  fqdn,
664
900
  certDirClean,
665
901
  port,
666
902
  domain,
667
- { pathPrefix, maxBodySizeMb, restricted } = {},
903
+ { pathPrefix, maxBodySizeMb, userSurface = [], restricted } = {},
668
904
  ) {
669
905
  const url = originalUrl(fqdn);
670
906
  return `server {
@@ -681,24 +917,11 @@ ${bodySizeDirective(maxBodySizeMb)}
681
917
  # Gatekeeper authorization subrequest (handles Authelia validation + grant check).
682
918
  # The tunnel is named in the path — never by the client's Host header.
683
919
  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;
920
+ ${gatekeeperSubrequest('tunnel', fqdn)}
698
921
  proxy_buffers 4 32k;
699
922
  proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
700
923
  }
701
-
924
+ ${userSurfaceLocations(fqdn, userSurface)}
702
925
  location / {
703
926
  auth_request /internal/lamaste/authz;
704
927
  auth_request_set $user $upstream_http_remote_user;
@@ -721,7 +944,11 @@ ${bodySizeDirective(maxBodySizeMb)}
721
944
  # The user, signed by Gatekeeper for this one request — lamaste-agentd,
722
945
  # which serves plugin routes, trusts no Remote-* header without it
723
946
  proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;`
724
- : ''
947
+ : `
948
+
949
+ # No relay assertion reaches an app: Gatekeeper signs one only for a
950
+ # plugin tunnel or the panel, never one a client sent
951
+ proxy_set_header X-Lamalibre-Relay-Assertion "";`
725
952
  }
726
953
  ${TUNNEL_PROXY_HEADERS}
727
954
 
@@ -1148,7 +1375,9 @@ export async function writeAgentPanelVhost(
1148
1375
  * reached without going through nginx, so a header proves nothing to it.
1149
1376
  *
1150
1377
  * `X-SSL-Client-*` still go upstream for agents older than 4.3, which read
1151
- * them; lamaste-agentd from 4.3 removes them unread.
1378
+ * them; lamaste-agentd from 4.3 removes them unread. Authelia's session
1379
+ * cookie never does ({@link BACKEND_COOKIE_HEADER}): the agent panel's host
1380
+ * is under the base domain like every other.
1152
1381
  *
1153
1382
  * @param {string} subdomain - `agent-<label>`
1154
1383
  * @param {string} domain
@@ -1208,6 +1437,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1208
1437
  proxy_set_header Remote-Groups "";
1209
1438
  proxy_set_header Remote-Name "";
1210
1439
  proxy_set_header Remote-Email "";
1440
+ proxy_set_header X-Lamalibre-Relay-Assertion "";
1211
1441
  }
1212
1442
 
1213
1443
  # Reject requests without valid client certificate
@@ -1236,6 +1466,7 @@ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1236
1466
  proxy_set_header Remote-Groups "";
1237
1467
  proxy_set_header Remote-Name "";
1238
1468
  proxy_set_header Remote-Email "";
1469
+ ${BACKEND_COOKIE_HEADER}
1239
1470
 
1240
1471
  # Standard proxy headers
1241
1472
  proxy_set_header Host $host;