@lamalibre/create-lamaste 4.2.0 → 4.3.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 (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  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 +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  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 +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { execa } from 'execa';
2
- import { access, lstat } from 'node:fs/promises';
2
+ import { access, lstat, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
 
5
5
  const SITES_AVAILABLE = '/etc/nginx/sites-available';
@@ -14,6 +14,14 @@ const SITES_ENABLED = '/etc/nginx/sites-enabled';
14
14
  */
15
15
  const PRIV = '/usr/local/sbin/lamaste-priv';
16
16
 
17
+ /**
18
+ * Ceiling for one nginx-related command (a `lamaste-priv` action, `nginx -t`,
19
+ * a reload). Each takes well under a second; a hung one must fail — and roll
20
+ * the vhost back — rather than hold up the caller, which at startup is the
21
+ * panel before it listens.
22
+ */
23
+ const NGINX_COMMAND_TIMEOUT_MS = 60_000;
24
+
17
25
  /**
18
26
  * Run one `lamaste-priv nginx-site` action.
19
27
  *
@@ -24,6 +32,7 @@ const PRIV = '/usr/local/sbin/lamaste-priv';
24
32
  async function siteAction(action, name, content) {
25
33
  try {
26
34
  await execa('sudo', [PRIV, 'nginx-site', action, name], {
35
+ timeout: NGINX_COMMAND_TIMEOUT_MS,
27
36
  ...(content === undefined ? {} : { input: content }),
28
37
  });
29
38
  } catch (err) {
@@ -242,11 +251,75 @@ server {
242
251
  return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
243
252
  }
244
253
 
254
+ /**
255
+ * A hostname written into a vhost as a literal. Where it names the vhost to
256
+ * an authorization subrequest it must stay a literal — a `$` would make nginx
257
+ * expand a variable there — so it is checked here, not trusted to the
258
+ * callers' validation.
259
+ */
260
+ const LITERAL_HOSTNAME_RE =
261
+ /^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
262
+
263
+ function assertLiteralHostname(name) {
264
+ if (typeof name !== 'string' || !LITERAL_HOSTNAME_RE.test(name)) {
265
+ throw new Error(`Invalid hostname for nginx vhost: ${name}`);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * The URL an authorization subrequest asks about: the vhost's own hostname,
271
+ * written into the configuration, and the request's path and query.
272
+ *
273
+ * Never `$http_host`. nginx selects the vhost from the request line when the
274
+ * request is in absolute form (`GET https://a.example.com/x HTTP/1.1`) and
275
+ * from `Host` otherwise, so a client can send the request line of one
276
+ * hostname with the `Host` header of another: built from `$http_host`, the
277
+ * subrequest asked Authelia (and Gatekeeper) about the other hostname while
278
+ * nginx served this one. `$host` is no better — on the default server it is
279
+ * whatever `Host` the client sent. Every vhost here listens on 443 with TLS
280
+ * only, so the scheme is fixed too. `$request_uri` is what follows the host in
281
+ * the request target — it starts with `/`, or with `?` for an absolute-form
282
+ * target without a path — so it cannot change the URL's host.
283
+ *
284
+ * @param {string} fqdn
285
+ */
286
+ function originalUrl(fqdn) {
287
+ assertLiteralHostname(fqdn);
288
+ return `https://${fqdn}$request_uri`;
289
+ }
290
+
291
+ /**
292
+ * Cleared in every authorization subrequest. Each names a host, a scheme or a
293
+ * path, and the client's values would otherwise reach Gatekeeper and
294
+ * Authelia; neither decides by them today, and neither may.
295
+ */
296
+ const AUTHZ_CLEARED_HEADERS = `
297
+ proxy_set_header X-Forwarded-Host "";
298
+ proxy_set_header X-Forwarded-Proto "";
299
+ proxy_set_header X-Forwarded-Uri "";
300
+ proxy_set_header X-Forwarded-Method "";`;
301
+
245
302
  /**
246
303
  * Write the Authelia auth portal vhost.
247
304
  */
248
305
  export async function writeAuthVhost(domain) {
306
+ const { name, config } = renderAuthVhost(domain);
307
+ await writeVhostFile(name, config);
308
+ return path.join(SITES_AVAILABLE, name);
309
+ }
310
+
311
+ /**
312
+ * Render the Authelia auth portal vhost. Pure; the startup reconcile renders
313
+ * it on the certificate directory the installed vhost names.
314
+ *
315
+ * @param {string} domain
316
+ * @param {string} [certPath] - certificate directory (default: the portal's own lineage)
317
+ * @returns {{ name: string, fqdn: string, config: string }}
318
+ */
319
+ export function renderAuthVhost(domain, certPath) {
249
320
  const fqdn = `auth.${domain}`;
321
+ let certDirClean = certPath || `/etc/letsencrypt/live/${fqdn}`;
322
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
250
323
  const config = `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
251
324
  limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
252
325
 
@@ -254,8 +327,8 @@ server {
254
327
  listen 443 ssl;
255
328
  server_name ${fqdn};
256
329
 
257
- ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
258
- ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
330
+ ssl_certificate ${certDirClean}/fullchain.pem;
331
+ ssl_certificate_key ${certDirClean}/privkey.pem;
259
332
 
260
333
  ssl_protocols TLSv1.2 TLSv1.3;
261
334
  ssl_ciphers HIGH:!aNULL:!MD5;
@@ -292,8 +365,9 @@ server {
292
365
  proxy_set_header Content-Length "";
293
366
  proxy_set_header Connection "";
294
367
  proxy_set_header X-Original-Method $request_method;
295
- proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
296
- proxy_set_header X-Forwarded-For $remote_addr;
368
+ # This vhost's hostname, never the client's Host header
369
+ proxy_set_header X-Original-URL ${originalUrl(fqdn)};
370
+ proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
297
371
 
298
372
  proxy_http_version 1.1;
299
373
  proxy_buffers 4 32k;
@@ -331,8 +405,7 @@ server {
331
405
  }
332
406
  `;
333
407
 
334
- await writeVhostFile('lamalibre-lamaste-auth', config);
335
- return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-auth');
408
+ return { name: 'lamalibre-lamaste-auth', fqdn, config };
336
409
  }
337
410
 
338
411
  /**
@@ -429,6 +502,14 @@ export async function writePublicVhost(
429
502
  certPath,
430
503
  { pathPrefix, maxBodySizeMb, enabled = true } = {},
431
504
  ) {
505
+ const { name, fqdn, config } = renderPublicVhost(subdomain, domain, port, certPath, {
506
+ pathPrefix,
507
+ maxBodySizeMb,
508
+ });
509
+ return safeWriteVhost(name, config, fqdn, { enabled });
510
+ }
511
+
512
+ function renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb } = {}) {
432
513
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
433
514
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
434
515
  }
@@ -455,7 +536,14 @@ ${bodySizeDirective(maxBodySizeMb)}
455
536
  proxy_set_header Remote-User "";
456
537
  proxy_set_header Remote-Groups "";
457
538
  proxy_set_header Remote-Name "";
458
- proxy_set_header Remote-Email "";
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
+ }
459
547
  ${TUNNEL_PROXY_HEADERS}
460
548
 
461
549
  ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
@@ -467,8 +555,7 @@ ${TUNNEL_PROXY_HEADERS}
467
555
  }
468
556
  `;
469
557
 
470
- const name = `lamalibre-lamaste-app-${subdomain}`;
471
- return safeWriteVhost(name, config, fqdn, { enabled });
558
+ return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
472
559
  }
473
560
 
474
561
  /**
@@ -483,22 +570,11 @@ export async function writeAuthenticatedVhost(
483
570
  certPath,
484
571
  { pathPrefix, maxBodySizeMb, enabled = true } = {},
485
572
  ) {
486
- if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
487
- throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
488
- }
489
-
490
- const fqdn = `${subdomain}.${domain}`;
491
- const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
492
- let certDirClean = certDir;
493
- while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
494
-
495
- const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
573
+ const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
496
574
  pathPrefix,
497
575
  maxBodySizeMb,
498
576
  restricted: false,
499
577
  });
500
-
501
- const name = `lamalibre-lamaste-app-${subdomain}`;
502
578
  return safeWriteVhost(name, config, fqdn, { enabled });
503
579
  }
504
580
 
@@ -513,6 +589,21 @@ export async function writeRestrictedVhost(
513
589
  port,
514
590
  certPath,
515
591
  { pathPrefix, maxBodySizeMb, enabled = true } = {},
592
+ ) {
593
+ const { name, fqdn, config } = renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
594
+ pathPrefix,
595
+ maxBodySizeMb,
596
+ restricted: true,
597
+ });
598
+ return safeWriteVhost(name, config, fqdn, { enabled });
599
+ }
600
+
601
+ function renderGatekeeperTunnelVhost(
602
+ subdomain,
603
+ domain,
604
+ port,
605
+ certPath,
606
+ { pathPrefix, maxBodySizeMb, restricted },
516
607
  ) {
517
608
  if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
518
609
  throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
@@ -526,11 +617,30 @@ export async function writeRestrictedVhost(
526
617
  const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
527
618
  pathPrefix,
528
619
  maxBodySizeMb,
529
- restricted: true,
620
+ restricted,
530
621
  });
531
622
 
532
- const name = `lamalibre-lamaste-app-${subdomain}`;
533
- return safeWriteVhost(name, config, fqdn, { enabled });
623
+ return { name: `lamalibre-lamaste-app-${subdomain}`, fqdn, config };
624
+ }
625
+
626
+ /**
627
+ * Render the vhost of an app or plugin tunnel — what the tunnel workflows
628
+ * write for `accessMode`. Pure; used to compare an installed vhost with what
629
+ * the generators produce today (see vhost-reconcile.js).
630
+ *
631
+ * @param {'public'|'authenticated'|'restricted'} accessMode
632
+ * @returns {{ name: string, fqdn: string, config: string }}
633
+ */
634
+ export function renderTunnelVhost(accessMode, subdomain, domain, port, certPath, opts = {}) {
635
+ const { pathPrefix, maxBodySizeMb } = opts;
636
+ if (accessMode === 'public') {
637
+ return renderPublicVhost(subdomain, domain, port, certPath, { pathPrefix, maxBodySizeMb });
638
+ }
639
+ return renderGatekeeperTunnelVhost(subdomain, domain, port, certPath, {
640
+ pathPrefix,
641
+ maxBodySizeMb,
642
+ restricted: accessMode !== 'authenticated',
643
+ });
534
644
  }
535
645
 
536
646
  /**
@@ -538,6 +648,16 @@ export async function writeRestrictedVhost(
538
648
  * Both 'authenticated' and 'restricted' modes use this — the difference
539
649
  * is handled server-side by Gatekeeper based on the tunnel's accessMode.
540
650
  * For restricted mode, the 403 body from Gatekeeper is served inline.
651
+ *
652
+ * The subrequest names the tunnel in its path, `/authz/tunnel/<fqdn>`,
653
+ * written here as the agent-panel vhost writes its agent's label, and
654
+ * `X-Original-URL` and `Host` carry the same hostname (see
655
+ * {@link originalUrl}). Vhosts before 4.3 called `/authz/check` with both
656
+ * built from the client's `Host`, so an Authelia user allowed on one tunnel
657
+ * reached any other — the request line named the tunnel nginx served, `Host`
658
+ * the one Gatekeeper authorized. Gatekeeper now refuses `/authz/check`: a
659
+ * vhost the panel has not rewritten closes its tunnel instead of opening it.
660
+ * The login redirect names this vhost for the same reason.
541
661
  */
542
662
  function buildGatekeeperVhost(
543
663
  fqdn,
@@ -546,6 +666,7 @@ function buildGatekeeperVhost(
546
666
  domain,
547
667
  { pathPrefix, maxBodySizeMb, restricted } = {},
548
668
  ) {
669
+ const url = originalUrl(fqdn);
549
670
  return `server {
550
671
  listen 443 ssl;
551
672
  server_name ${fqdn};
@@ -557,19 +678,20 @@ function buildGatekeeperVhost(
557
678
  ssl_ciphers HIGH:!aNULL:!MD5;
558
679
  ssl_prefer_server_ciphers on;
559
680
  ${bodySizeDirective(maxBodySizeMb)}
560
- # Gatekeeper authorization subrequest (handles Authelia validation + grant check)
681
+ # Gatekeeper authorization subrequest (handles Authelia validation + grant check).
682
+ # The tunnel is named in the path — never by the client's Host header.
561
683
  location /internal/lamaste/authz {
562
684
  internal;
563
685
 
564
- proxy_pass http://127.0.0.1:9294/authz/check;
686
+ proxy_pass http://127.0.0.1:9294/authz/tunnel/${fqdn};
565
687
  proxy_pass_request_body off;
566
688
 
567
689
  proxy_set_header Content-Length "";
568
690
  proxy_set_header Connection "";
569
691
  proxy_set_header X-Original-Method $request_method;
570
- proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
571
- proxy_set_header X-Forwarded-For $remote_addr;
572
- proxy_set_header Host $http_host;
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};
573
695
  proxy_set_header Cookie $http_cookie;
574
696
 
575
697
  proxy_http_version 1.1;
@@ -582,12 +704,25 @@ ${bodySizeDirective(maxBodySizeMb)}
582
704
  auth_request_set $user $upstream_http_remote_user;
583
705
  auth_request_set $groups $upstream_http_remote_groups;
584
706
  auth_request_set $name $upstream_http_remote_name;
585
- auth_request_set $email $upstream_http_remote_email;
707
+ auth_request_set $email $upstream_http_remote_email;${
708
+ pathPrefix
709
+ ? `
710
+ auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;`
711
+ : ''
712
+ }
586
713
 
587
714
  proxy_set_header Remote-User $user;
588
715
  proxy_set_header Remote-Groups $groups;
589
716
  proxy_set_header Remote-Name $name;
590
- proxy_set_header Remote-Email $email;
717
+ proxy_set_header Remote-Email $email;${
718
+ pathPrefix
719
+ ? `
720
+
721
+ # The user, signed by Gatekeeper for this one request — lamaste-agentd,
722
+ # which serves plugin routes, trusts no Remote-* header without it
723
+ proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;`
724
+ : ''
725
+ }
591
726
  ${TUNNEL_PROXY_HEADERS}
592
727
 
593
728
  ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
@@ -596,8 +731,8 @@ ${TUNNEL_PROXY_HEADERS}
596
731
  proxy_read_timeout 86400s;
597
732
  proxy_send_timeout 86400s;
598
733
 
599
- # Authentication failure -> redirect to Authelia login
600
- error_page 401 =302 https://auth.${domain}/?rd=$scheme://$http_host$request_uri;
734
+ # Authentication failure -> redirect to Authelia login, back to this vhost
735
+ error_page 401 =302 https://auth.${domain}/?rd=${url};
601
736
 
602
737
  ${
603
738
  restricted
@@ -611,7 +746,7 @@ ${TUNNEL_PROXY_HEADERS}
611
746
  }
612
747
 
613
748
  /** True when `name` is linked into sites-enabled. The directory is world-readable. */
614
- async function isSiteEnabled(name) {
749
+ export async function isSiteEnabled(name) {
615
750
  try {
616
751
  await lstat(path.join(SITES_ENABLED, name));
617
752
  return true;
@@ -703,6 +838,24 @@ async function safeWriteVhost(name, config, fqdn, { enabled = true } = {}) {
703
838
  * @param {string} [domain] - Base domain (needed for Authelia redirect URL)
704
839
  */
705
840
  export async function writeStaticSiteVhost(site, certDir, domain) {
841
+ const { name, fqdn, config } = renderStaticSiteVhost(site, certDir, domain);
842
+ return safeWriteVhost(name, config, fqdn, { enabled: true });
843
+ }
844
+
845
+ /**
846
+ * Render a static site vhost. Pure; the startup reconcile renders it on the
847
+ * certificate directory the installed vhost names.
848
+ *
849
+ * Authelia decides by the hostname of `X-Original-URL` — a site restricted to
850
+ * some users has rules for its own hostname only — so the subrequest names
851
+ * this site, never the client's `Host` (see {@link originalUrl}).
852
+ *
853
+ * @param {object} site - as for writeStaticSiteVhost
854
+ * @param {string} certDir
855
+ * @param {string} [domain]
856
+ * @returns {{ name: string, fqdn: string, config: string }}
857
+ */
858
+ export function renderStaticSiteVhost(site, certDir, domain) {
706
859
  let certDirClean = certDir;
707
860
  while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
708
861
  const aliasBlock = buildAliasRedirectBlock(site, certDirClean);
@@ -723,8 +876,9 @@ export async function writeStaticSiteVhost(site, certDir, domain) {
723
876
  proxy_set_header Content-Length "";
724
877
  proxy_set_header Connection "";
725
878
  proxy_set_header X-Original-Method $request_method;
726
- proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
727
- proxy_set_header X-Forwarded-For $remote_addr;
879
+ # This site's hostname, never the client's Host header
880
+ proxy_set_header X-Original-URL ${originalUrl(site.fqdn)};
881
+ proxy_set_header X-Forwarded-For $remote_addr;${AUTHZ_CLEARED_HEADERS}
728
882
 
729
883
  proxy_http_version 1.1;
730
884
  proxy_buffers 4 32k;
@@ -779,8 +933,7 @@ ${
779
933
  }
780
934
  ${aliasBlock}`;
781
935
 
782
- const name = `lamalibre-lamaste-site-${site.id}`;
783
- return safeWriteVhost(name, config, site.fqdn, { enabled: true });
936
+ return { name: `lamalibre-lamaste-site-${site.id}`, fqdn: site.fqdn, config };
784
937
  }
785
938
 
786
939
  /**
@@ -861,7 +1014,7 @@ export async function removeAppVhost(subdomain) {
861
1014
  */
862
1015
  export async function testConfig() {
863
1016
  try {
864
- await execa('sudo', ['nginx', '-t']);
1017
+ await execa('sudo', ['nginx', '-t'], { timeout: NGINX_COMMAND_TIMEOUT_MS });
865
1018
  return { valid: true };
866
1019
  } catch (err) {
867
1020
  return { valid: false, error: err.stderr || err.message };
@@ -873,7 +1026,7 @@ export async function testConfig() {
873
1026
  */
874
1027
  export async function reload() {
875
1028
  try {
876
- await execa('sudo', ['systemctl', 'reload', 'nginx']);
1029
+ await execa('sudo', ['systemctl', 'reload', 'nginx'], { timeout: NGINX_COMMAND_TIMEOUT_MS });
877
1030
  return { reloaded: true };
878
1031
  } catch (err) {
879
1032
  throw new Error(`Failed to reload nginx: ${err.stderr || err.message}`);
@@ -977,6 +1130,40 @@ export async function writeAgentPanelVhost(
977
1130
  certPath,
978
1131
  { enabled = true } = {},
979
1132
  ) {
1133
+ const { name, fqdn, config } = renderAgentPanelVhost(subdomain, domain, port, certPath);
1134
+ return safeWriteVhost(name, config, fqdn, { enabled });
1135
+ }
1136
+
1137
+ /**
1138
+ * Render an agent panel vhost. Pure.
1139
+ *
1140
+ * nginx verifies the browser's client certificate, then asks the panel
1141
+ * whether the request may reach the agent (`auth_request` to
1142
+ * `/api/agent-panel/authz/<label>`, with the certificate's verification
1143
+ * result, the method and `$request_uri`). The panel refuses a revoked or
1144
+ * foreign certificate and otherwise signs a relay assertion for exactly this
1145
+ * request, which goes to the agent's lamaste-agentd in
1146
+ * `X-Lamalibre-Relay-Assertion`. The daemon accepts nothing else from the
1147
+ * relay: its port, and the tunnel's port on the relay's loopback, can be
1148
+ * reached without going through nginx, so a header proves nothing to it.
1149
+ *
1150
+ * `X-SSL-Client-*` still go upstream for agents older than 4.3, which read
1151
+ * them; lamaste-agentd from 4.3 removes them unread.
1152
+ *
1153
+ * @param {string} subdomain - `agent-<label>`
1154
+ * @param {string} domain
1155
+ * @param {number} port
1156
+ * @param {string} [certPath]
1157
+ * @returns {{ name: string, fqdn: string, config: string }}
1158
+ */
1159
+ export function renderAgentPanelVhost(subdomain, domain, port, certPath) {
1160
+ if (!/^agent-[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain)) {
1161
+ throw new Error(`Invalid agent panel subdomain for nginx vhost: ${subdomain}`);
1162
+ }
1163
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1164
+ throw new Error(`Invalid port for nginx vhost: ${port}`);
1165
+ }
1166
+ const label = subdomain.slice('agent-'.length);
980
1167
  const fqdn = `${subdomain}.${domain}`;
981
1168
  const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
982
1169
  let certDirClean = certDir;
@@ -996,16 +1183,50 @@ export async function writeAgentPanelVhost(
996
1183
  ssl_ciphers HIGH:!aNULL:!MD5;
997
1184
  ssl_prefer_server_ciphers on;
998
1185
 
1186
+ # Authorization by the panel: refuses revoked and foreign certificates,
1187
+ # otherwise answers with the relay assertion for this one request
1188
+ location = /internal/lamaste/agent-panel-authz {
1189
+ internal;
1190
+
1191
+ proxy_pass http://127.0.0.1:3100/api/agent-panel/authz/${label};
1192
+ proxy_pass_request_body off;
1193
+ proxy_http_version 1.1;
1194
+
1195
+ proxy_set_header Content-Length "";
1196
+ proxy_set_header Connection "";
1197
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
1198
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
1199
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
1200
+ proxy_set_header X-Original-Method $request_method;
1201
+ proxy_set_header X-Original-URI $request_uri;
1202
+ proxy_set_header X-Forwarded-For $remote_addr;
1203
+
1204
+ # Nothing else of the browser's request is the panel's business
1205
+ proxy_set_header Cookie "";
1206
+ proxy_set_header Authorization "";
1207
+ proxy_set_header Remote-User "";
1208
+ proxy_set_header Remote-Groups "";
1209
+ proxy_set_header Remote-Name "";
1210
+ proxy_set_header Remote-Email "";
1211
+ }
1212
+
999
1213
  # Reject requests without valid client certificate
1000
1214
  location / {
1001
1215
  if ($ssl_client_verify != SUCCESS) {
1002
1216
  return 496;
1003
1217
  }
1004
1218
 
1219
+ auth_request /internal/lamaste/agent-panel-authz;
1220
+ auth_request_set $relay_assertion $upstream_http_x_lamalibre_relay_assertion;
1221
+
1005
1222
  proxy_pass http://127.0.0.1:${port};
1006
1223
  proxy_http_version 1.1;
1007
1224
 
1008
- # Client cert headers — set from nginx TLS variables, never passed through from client
1225
+ # The caller, signed by the panel for this one request
1226
+ proxy_set_header X-Lamalibre-Relay-Assertion $relay_assertion;
1227
+
1228
+ # Client cert headers — set from nginx TLS variables, never passed through
1229
+ # from client; read only by agents older than 4.3
1009
1230
  proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
1010
1231
  proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
1011
1232
  proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
@@ -1031,10 +1252,43 @@ export async function writeAgentPanelVhost(
1031
1252
  }
1032
1253
  `;
1033
1254
 
1034
- const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1035
- return safeWriteVhost(name, config, fqdn, { enabled });
1255
+ return { name: `lamalibre-lamaste-agent-panel-${subdomain}`, fqdn, config };
1036
1256
  }
1037
1257
 
1258
+ /**
1259
+ * The installed sites-available/<name>, or null when there is none. The
1260
+ * directory is world-readable.
1261
+ *
1262
+ * @param {string} name
1263
+ * @returns {Promise<string | null>}
1264
+ */
1265
+ export async function readInstalledVhost(name) {
1266
+ try {
1267
+ return await readFile(path.join(SITES_AVAILABLE, name), 'utf8');
1268
+ } catch (err) {
1269
+ if (err.code === 'ENOENT') return null;
1270
+ throw err;
1271
+ }
1272
+ }
1273
+
1274
+ /**
1275
+ * The certificate directory a vhost's `ssl_certificate` names, without the
1276
+ * trailing slash — so a vhost can be rendered again on the certificate it was
1277
+ * written for (a tunnel may be on a wildcard certificate). Null when the
1278
+ * vhost names none.
1279
+ *
1280
+ * @param {string} config
1281
+ * @returns {string | null}
1282
+ */
1283
+ export function vhostCertDir(config) {
1284
+ const match = /^\s*ssl_certificate\s+(\S+)\/fullchain\.pem;/m.exec(config);
1285
+ return match ? match[1] : null;
1286
+ }
1287
+
1288
+ // Install a rendered vhost (vhost-reconcile.js) through the same backup /
1289
+ // write / link state / test / reload / rollback path as every vhost write.
1290
+ export { safeWriteVhost as installVhost };
1291
+
1038
1292
  /**
1039
1293
  * Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
1040
1294
  * Idempotent: if files don't exist, proceeds silently.
@@ -0,0 +1,107 @@
1
+ // Panel notifications — messages for the relay's administrators: the
2
+ // `notify-admins` effect of plugins' lifecycle hooks (rate-limited by the
3
+ // effects executor) and Lamaste's own events (an approval waiting, a rule
4
+ // pack updated). Stored in state.db; read state is shared because the panel
5
+ // has one administrator identity. Plain text only — the panel renders it as
6
+ // text, never as HTML.
7
+
8
+ import { getStateDb } from './state-db.js';
9
+
10
+ export const NOTIFICATION_LEVELS = ['info', 'warning', 'critical'];
11
+ export const NOTIFICATION_MESSAGE_MAX = 500;
12
+ export const NOTIFICATIONS_MAX_ROWS = 1000;
13
+ export const NOTIFICATIONS_RETENTION_DAYS = 30;
14
+
15
+ const SOURCE_RE = /^[A-Za-z0-9:._-]{1,120}$/;
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/g;
18
+
19
+ function cleanMessage(message) {
20
+ // Control characters out (a message is one line of text), length capped.
21
+ const text = String(message).replace(CONTROL_CHARS, ' ').trim();
22
+ return text.length > NOTIFICATION_MESSAGE_MAX
23
+ ? `${text.slice(0, NOTIFICATION_MESSAGE_MAX - 1)}…`
24
+ : text;
25
+ }
26
+
27
+ /**
28
+ * @param {{ source: string, level?: 'info'|'warning'|'critical', message: string }} n
29
+ * `source` — `plugin:<route>` or `lamaste`.
30
+ */
31
+ export async function notifyAdmins({ source, level = 'info', message }) {
32
+ if (!SOURCE_RE.test(source)) throw new Error('Invalid notification source');
33
+ if (!NOTIFICATION_LEVELS.includes(level)) throw new Error('Invalid notification level');
34
+ const text = cleanMessage(message);
35
+ if (text.length === 0) throw new Error('Empty notification');
36
+ const db = await getStateDb();
37
+ const at = new Date().toISOString();
38
+ const result = db
39
+ .prepare('INSERT INTO notifications (at, source, level, message) VALUES (?, ?, ?, ?)')
40
+ .run(at, source, level, text);
41
+ const cutoff = new Date(Date.now() - NOTIFICATIONS_RETENTION_DAYS * 86_400_000).toISOString();
42
+ db.prepare('DELETE FROM notifications WHERE at < ?').run(cutoff);
43
+ db.prepare(
44
+ 'DELETE FROM notifications WHERE id <= (SELECT id FROM notifications ORDER BY id DESC LIMIT 1 OFFSET ?)',
45
+ ).run(NOTIFICATIONS_MAX_ROWS);
46
+ return { id: Number(result.lastInsertRowid), at };
47
+ }
48
+
49
+ export async function notifyAdminsBestEffort(notification, logger) {
50
+ try {
51
+ return await notifyAdmins(notification);
52
+ } catch (err) {
53
+ logger?.error?.({ err: err.message }, 'Failed to store a notification');
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function rowToNotification(row) {
59
+ return {
60
+ id: row.id,
61
+ at: row.at,
62
+ source: row.source,
63
+ level: row.level,
64
+ message: row.message,
65
+ read: row.read_at !== null,
66
+ readAt: row.read_at,
67
+ };
68
+ }
69
+
70
+ /** Newest first, with the unread count. */
71
+ export async function listNotifications({ unreadOnly = false, before, limit = 50 } = {}) {
72
+ const db = await getStateDb();
73
+ const where = [];
74
+ const args = [];
75
+ if (unreadOnly) where.push('read_at IS NULL');
76
+ if (before) {
77
+ where.push('id < ?');
78
+ args.push(before);
79
+ }
80
+ args.push(Math.min(Math.max(1, limit), 200));
81
+ const rows = db
82
+ .prepare(
83
+ `SELECT id, at, source, level, message, read_at FROM notifications${
84
+ where.length ? ` WHERE ${where.join(' AND ')}` : ''
85
+ } ORDER BY id DESC LIMIT ?`,
86
+ )
87
+ .all(...args);
88
+ const { unread } = db
89
+ .prepare('SELECT COUNT(*) AS unread FROM notifications WHERE read_at IS NULL')
90
+ .get();
91
+ return { notifications: rows.map(rowToNotification), unread: Number(unread) };
92
+ }
93
+
94
+ /** Mark the given ids — or every unread notification — read. Returns how many changed. */
95
+ export async function markNotificationsRead({ ids, all = false }) {
96
+ const db = await getStateDb();
97
+ const at = new Date().toISOString();
98
+ if (all) {
99
+ return Number(
100
+ db.prepare('UPDATE notifications SET read_at = ? WHERE read_at IS NULL').run(at).changes,
101
+ );
102
+ }
103
+ let changed = 0;
104
+ const stmt = db.prepare('UPDATE notifications SET read_at = ? WHERE id = ? AND read_at IS NULL');
105
+ for (const id of ids ?? []) changed += Number(stmt.run(at, id).changes);
106
+ return changed;
107
+ }