@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-lamaste",
3
- "version": "4.4.1",
3
+ "version": "4.6.0",
4
4
  "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -43,7 +43,7 @@
43
43
  "mtls"
44
44
  ],
45
45
  "dependencies": {
46
- "@lamalibre/lamaste": "^4.4.1",
46
+ "@lamalibre/lamaste": "^4.6.0",
47
47
  "chalk": "^5.3.0",
48
48
  "execa": "^9.0.0",
49
49
  "listr2": "^8.0.0"
@@ -16,6 +16,14 @@
16
16
  * nginx-site disable <name> unlink <name> from sites-enabled
17
17
  * nginx-site remove <name> delete <name>, its link and its backup
18
18
  * authelia-totp <username> stdin: base32 secret
19
+ * authelia-user purge-second-factors <username>
20
+ * delete the user's TOTP configuration and
21
+ * every WebAuthn credential from Authelia's
22
+ * storage (nothing there is fine)
23
+ * authelia-user has-oidc-identifier <username>
24
+ * print `yes` when Authelia's storage holds an
25
+ * OpenID Connect opaque identifier for the
26
+ * username, `no` otherwise — nothing else
19
27
  * self-update <version> run create-lamaste@<version> detached
20
28
  * hooks install <route> <sha256|none> stdin: the plugin's approved relay hook
21
29
  * module (none: shared hooks only); start its runner
@@ -243,6 +251,12 @@ const WEBROOT_RE = /^\/var\/www\/lamaste\/[A-Za-z0-9._/-]*$/;
243
251
  const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,64}$/;
244
252
  const VARIABLE_RE = /^\$[A-Za-z_][A-Za-z0-9_]{0,63}$/;
245
253
  const URI_PATH_RE = /^\/[A-Za-z0-9._~/-]*$/;
254
+ /** A named location (`location @previous`), the target of a try_files fallback. */
255
+ const NAMED_LOCATION_RE = /^@[a-z][a-z0-9_]{0,31}$/;
256
+ const TRY_FILES_ARG_RE = /^[A-Za-z0-9._/$=-]{1,256}$/;
257
+ /** A `types` entry: a media type, then the extensions served with it. */
258
+ const MEDIA_TYPE_RE = /^[a-z]+\/[a-z0-9.+-]{1,64}$/;
259
+ const EXTENSION_RE = /^[a-z0-9]{1,16}$/;
246
260
 
247
261
  function hasDotDotSegment(p) {
248
262
  return p.split('/').some((seg) => seg === '..' || seg === '.');
@@ -309,18 +323,24 @@ const RULES = {
309
323
  error_page: { args: errorPageArgs },
310
324
  return: { args: argc(1, 2) },
311
325
  rewrite: { args: rewriteArgs },
326
+ // A host whose every location asks the same authorization (a plugin's
327
+ // web surface): a URI of this server, never a file.
328
+ auth_request: { args: (a) => a.length === 1 && (a[0] === 'off' || URI_PATH_RE.test(a[0])) },
312
329
  location: { block: 'location', args: locationArgs },
313
330
  },
314
331
  location: {
315
332
  location: { block: 'location', args: locationArgs },
316
333
  if: { block: 'if', args: argc(1, 5) },
334
+ // The content types of a plugin's web surface, named by the vhost
335
+ // rather than the host's mime.types (entries checked below).
336
+ types: { block: 'types', args: argc(0, 0) },
317
337
  internal: { args: argc(0, 0) },
318
338
  return: { args: argc(1, 2) },
319
339
  rewrite: { args: rewriteArgs },
320
340
  root: { args: (a) => a.length === 1 && webrootPath(a[0]) },
321
341
  alias: { args: (a) => a.length === 1 && webrootPath(a[0]) },
322
342
  index: { args: each(/^[A-Za-z0-9._-]{1,64}$/) },
323
- try_files: { args: each(/^[A-Za-z0-9._/$=-]{1,256}$/) },
343
+ try_files: { args: tryFilesArgs },
324
344
  add_header: {
325
345
  args: (a) =>
326
346
  (a.length === 2 || (a.length === 3 && a[2] === 'always')) && HEADER_NAME_RE.test(a[0]),
@@ -378,8 +398,20 @@ function rewriteArgs(a) {
378
398
  );
379
399
  }
380
400
 
401
+ /** Files to try under the location's root; the last may instead name a location. */
402
+ function tryFilesArgs(a) {
403
+ if (a.length === 0) return false;
404
+ const last = a[a.length - 1];
405
+ return (
406
+ a.slice(0, -1).every((v) => TRY_FILES_ARG_RE.test(v)) &&
407
+ (TRY_FILES_ARG_RE.test(last) || NAMED_LOCATION_RE.test(last))
408
+ );
409
+ }
410
+
381
411
  function locationArgs(a) {
382
- if (a.length === 1) return a[0].length <= 512 && a[0].startsWith('/');
412
+ if (a.length === 1) {
413
+ return (a[0].length <= 512 && a[0].startsWith('/')) || NAMED_LOCATION_RE.test(a[0]);
414
+ }
383
415
  return a.length === 2 && ['=', '~', '~*', '^~'].includes(a[0]) && a[1].length <= 512;
384
416
  }
385
417
 
@@ -399,6 +431,14 @@ function validateDirectives(directives, context) {
399
431
  }
400
432
  continue;
401
433
  }
434
+ if (context === 'types') {
435
+ // types entries are "media/type ext...;" — no block, no include.
436
+ if (d.block !== null) throw new UsageError(`vhost line ${d.line}: block inside types`);
437
+ if (!MEDIA_TYPE_RE.test(d.name) || !each(EXTENSION_RE)(d.args)) {
438
+ throw new UsageError(`vhost line ${d.line}: malformed types entry`);
439
+ }
440
+ continue;
441
+ }
402
442
  const rule = rules[d.name];
403
443
  if (!rule) {
404
444
  throw new UsageError(
@@ -583,22 +623,25 @@ function lookupId(flag, name) {
583
623
  return id;
584
624
  }
585
625
 
626
+ /** Same shape as the panel's username schema; a leading "-" would read as a flag. */
627
+ const AUTHELIA_USERNAME_RE = /^[a-z0-9_][a-z0-9_-]{1,31}$/;
628
+
629
+ function assertAutheliaUsername(username) {
630
+ if (typeof username !== 'string' || !AUTHELIA_USERNAME_RE.test(username)) {
631
+ throw new UsageError('invalid username');
632
+ }
633
+ }
634
+
586
635
  /**
587
636
  * Store a TOTP secret in Authelia's database with Authelia's own CLI, run as
588
637
  * the Authelia service user (the database is its file).
589
638
  */
590
639
  function autheliaTotp(username) {
591
- // Same shape as the panel's username schema; a leading "-" would read as a flag.
592
- if (typeof username !== 'string' || !/^[a-z0-9_][a-z0-9_-]{1,31}$/.test(username)) {
593
- throw new UsageError('invalid username');
594
- }
640
+ assertAutheliaUsername(username);
595
641
  const secret = readStdin(1024).trim();
596
642
  if (!/^[A-Z2-7]{16,128}$/.test(secret)) throw new UsageError('invalid TOTP secret');
597
643
 
598
- const uid = lookupId('-u', AUTHELIA_USER);
599
- const gid = lookupId('-g', AUTHELIA_USER);
600
- const result = spawnSync(
601
- AUTHELIA_BIN,
644
+ const result = runAutheliaCli(
602
645
  [
603
646
  'storage',
604
647
  'user',
@@ -615,11 +658,35 @@ function autheliaTotp(username) {
615
658
  '6',
616
659
  '--period',
617
660
  '30',
618
- '--config',
619
- AUTHELIA_CONFIG,
620
- '--',
621
- username,
622
661
  ],
662
+ username,
663
+ );
664
+ if (result.status !== 0) {
665
+ // Authelia's error output does not echo the secret; its success output
666
+ // (a QR code / otpauth URI) does, so only failures are relayed.
667
+ throw new Error(
668
+ `authelia exited with status ${result.status}: ${(result.stderr || '').trim()}`,
669
+ );
670
+ }
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Authelia's per-user storage
675
+ // ---------------------------------------------------------------------------
676
+
677
+ /**
678
+ * Run `authelia <args> --config <config> [-- <username>]` as the Authelia
679
+ * service account, with the environment the service has (its storage
680
+ * encryption key comes from the configuration).
681
+ * Opening the database as root would leave root-owned journal files the
682
+ * service cannot write.
683
+ */
684
+ function runAutheliaCli(args, username) {
685
+ const uid = lookupId('-u', AUTHELIA_USER);
686
+ const gid = lookupId('-g', AUTHELIA_USER);
687
+ const result = spawnSync(
688
+ AUTHELIA_BIN,
689
+ [...args, '--config', AUTHELIA_CONFIG, ...(username === undefined ? [] : ['--', username])],
623
690
  {
624
691
  uid,
625
692
  gid,
@@ -630,13 +697,156 @@ function autheliaTotp(username) {
630
697
  },
631
698
  );
632
699
  if (result.error) throw result.error;
633
- if (result.status !== 0) {
634
- // Authelia's error output does not echo the secret; its success output
635
- // (a QR code / otpauth URI) does, so only failures are relayed.
700
+ return result;
701
+ }
702
+
703
+ /** Everything Authelia printed, to match a known refusal in. */
704
+ function cliOutput(result) {
705
+ return `${result.stderr || ''}\n${result.stdout || ''}`;
706
+ }
707
+
708
+ /** Authelia's output with the username taken out: what this helper says may end up in a log. */
709
+ function redacted(text, username) {
710
+ return String(text || '')
711
+ .trim()
712
+ .split(username)
713
+ .join('<username>');
714
+ }
715
+
716
+ /**
717
+ * Delete the user's TOTP configuration and every WebAuthn credential from
718
+ * Authelia's storage. Authelia 4.39 refuses to delete a TOTP configuration
719
+ * that does not exist (`no TOTP configuration for user`), which here means
720
+ * there is nothing to delete; deleting WebAuthn credentials by username
721
+ * succeeds whatever number there are.
722
+ */
723
+ function autheliaPurgeSecondFactors(username) {
724
+ assertAutheliaUsername(username);
725
+ const totp = runAutheliaCli(['storage', 'user', 'totp', 'delete'], username);
726
+ if (totp.status !== 0 && !/no TOTP configuration for user/.test(cliOutput(totp))) {
636
727
  throw new Error(
637
- `authelia exited with status ${result.status}: ${(result.stderr || '').trim()}`,
728
+ `authelia storage user totp delete exited with status ${totp.status}: ${redacted(totp.stderr, username)}`,
729
+ );
730
+ }
731
+ const webauthn = runAutheliaCli(['storage', 'user', 'webauthn', 'delete', '--all'], username);
732
+ if (webauthn.status !== 0) {
733
+ throw new Error(
734
+ `authelia storage user webauthn delete exited with status ${webauthn.status}: ${redacted(webauthn.stderr, username)}`,
735
+ );
736
+ }
737
+ }
738
+
739
+ /** A YAML scalar as yaml.v3 writes a username: plain, or quoted when it would read as another type. */
740
+ function yamlScalar(text) {
741
+ const value = text.trim();
742
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
743
+ try {
744
+ return JSON.parse(value);
745
+ } catch {
746
+ return null;
747
+ }
748
+ }
749
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
750
+ return value.slice(1, -1).split("''").join("'");
751
+ }
752
+ return value;
753
+ }
754
+
755
+ /**
756
+ * Whether the YAML of `authelia storage user identifiers export` (a list of
757
+ * `{ service, sector_id, username, identifier }` under `identifiers`) names
758
+ * `username`.
759
+ */
760
+ function exportNamesUsername(text, username) {
761
+ for (const line of String(text).split('\n')) {
762
+ const match = /^\s*(?:-\s+)?username:(.*)$/.exec(line);
763
+ if (match && yamlScalar(match[1]) === username) return true;
764
+ }
765
+ return false;
766
+ }
767
+
768
+ /** Overwrite a file with zeros, flush it, and remove it. */
769
+ function shred(file) {
770
+ let fd = null;
771
+ try {
772
+ fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
773
+ const { size } = fs.fstatSync(fd);
774
+ const zeros = Buffer.alloc(Math.min(size, 65536));
775
+ for (let written = 0; written < size; written += zeros.length) {
776
+ fs.writeSync(fd, zeros, 0, Math.min(zeros.length, size - written), written);
777
+ }
778
+ fs.fsyncSync(fd);
779
+ } catch (err) {
780
+ if (err.code !== 'ENOENT') throw err;
781
+ } finally {
782
+ if (fd !== null) fs.closeSync(fd);
783
+ }
784
+ unlinkIfPresent(file);
785
+ }
786
+
787
+ /** The largest export read back: well beyond any opaque identifier table Lamaste serves. */
788
+ const MAX_IDENTIFIERS_EXPORT_BYTES = 32 * 1024 * 1024;
789
+
790
+ /**
791
+ * Whether Authelia's storage holds an OpenID Connect opaque identifier for
792
+ * the username. Authelia's CLI has no lookup by username, so it exports the
793
+ * table (as the Authelia account) into a directory only that account and
794
+ * root can enter, on /run (memory, not disk); the file is read, checked,
795
+ * overwritten and removed, whatever happens. Prints `yes` or `no`.
796
+ */
797
+ function autheliaHasOidcIdentifier(username) {
798
+ assertAutheliaUsername(username);
799
+ const uid = lookupId('-u', AUTHELIA_USER);
800
+ const gid = lookupId('-g', AUTHELIA_USER);
801
+ const dir = fs.mkdtempSync('/run/lamaste-priv-oidc-');
802
+ const file = path.join(dir, 'identifiers.yml');
803
+ try {
804
+ fs.chmodSync(dir, 0o700);
805
+ fs.chownSync(dir, uid, gid);
806
+ const result = runAutheliaCli(['storage', 'user', 'identifiers', 'export', '--file', file]);
807
+ if (result.status !== 0) {
808
+ // An empty table is not an export.
809
+ if (/no data to export/.test(cliOutput(result))) {
810
+ process.stdout.write('no\n');
811
+ return;
812
+ }
813
+ throw new Error(
814
+ `authelia storage user identifiers export exited with status ${result.status}: ${redacted(result.stderr, username)}`,
815
+ );
816
+ }
817
+ const st = fs.lstatSync(file);
818
+ if (!st.isFile() || st.uid !== uid)
819
+ throw new Error('the export is not the file Authelia wrote');
820
+ if (st.size > MAX_IDENTIFIERS_EXPORT_BYTES) throw new Error('the export is too large');
821
+ const named = exportNamesUsername(fs.readFileSync(file, 'utf8'), username);
822
+ process.stdout.write(named ? 'yes\n' : 'no\n');
823
+ } finally {
824
+ shred(file);
825
+ fs.rmSync(dir, { recursive: true, force: true });
826
+ }
827
+ }
828
+
829
+ const AUTHELIA_USER_ACTIONS = new Set(['purge-second-factors', 'has-oidc-identifier']);
830
+
831
+ /** `authelia-user <action> <username>`, checked before anything runs. */
832
+ function parseAutheliaUserArgs(rest) {
833
+ if (rest.length !== 2) {
834
+ throw new UsageError(
835
+ 'usage: authelia-user purge-second-factors|has-oidc-identifier <username>',
638
836
  );
639
837
  }
838
+ const [action, username] = rest;
839
+ if (!AUTHELIA_USER_ACTIONS.has(action)) {
840
+ throw new UsageError(`unknown authelia-user action: ${String(action).slice(0, 40)}`);
841
+ }
842
+ assertAutheliaUsername(username);
843
+ return { action, username };
844
+ }
845
+
846
+ function autheliaUser(rest) {
847
+ const { action, username } = parseAutheliaUserArgs(rest);
848
+ if (action === 'purge-second-factors') autheliaPurgeSecondFactors(username);
849
+ else autheliaHasOidcIdentifier(username);
640
850
  }
641
851
 
642
852
  /**
@@ -879,6 +1089,9 @@ function main(argv) {
879
1089
  if (rest.length !== 1) throw new UsageError('usage: authelia-totp <username>');
880
1090
  autheliaTotp(rest[0]);
881
1091
  return;
1092
+ case 'authelia-user':
1093
+ autheliaUser(rest);
1094
+ return;
882
1095
  case 'self-update':
883
1096
  if (rest.length !== 1) throw new UsageError('usage: self-update <version>');
884
1097
  selfUpdate(rest[0]);
@@ -910,6 +1123,9 @@ if (require.main === module) {
910
1123
  AUTHELIA_CLI_ENV,
911
1124
  assertHookRoute,
912
1125
  HOOK_ROUTE_RE,
1126
+ assertAutheliaUsername,
1127
+ parseAutheliaUserArgs,
1128
+ exportNamesUsername,
913
1129
  main,
914
1130
  };
915
1131
  }
package/src/index.js CHANGED
@@ -148,6 +148,7 @@ ${b('2. Remove nginx configuration')}
148
148
  ${c('sudo rm /etc/nginx/sites-enabled/lamalibre-lamaste-*')}
149
149
  ${c('sudo rm /etc/nginx/sites-available/lamalibre-lamaste-*')}
150
150
  ${c('sudo rm /etc/nginx/snippets/lamalibre-lamaste-*')}
151
+ ${c('sudo rm /etc/nginx/conf.d/lamalibre-lamaste-*')}
151
152
  ${c('sudo nginx -t && sudo systemctl reload nginx')}
152
153
 
153
154
  ${b('3. Remove Lamaste directories and binaries')}
@@ -0,0 +1,65 @@
1
+ import { execa } from 'execa';
2
+ import { constants } from 'node:fs';
3
+ import { mkdir, open, rm } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { BACKEND_COOKIE_MAP_FILE, renderBackendCookieMap } from '@lamalibre/lamaste';
6
+ import { writeFileNoFollow } from './ownership.js';
7
+
8
+ /**
9
+ * The http-level nginx map that keeps Authelia's session cookie from every
10
+ * tunnel and agent backend (core `renderBackendCookieMap`: the cookie name
11
+ * Authelia is configured with, stripped pair by pair, no `Cookie` header at
12
+ * all when one is left). Every vhost the panel renders for a tunnel or an
13
+ * agent panel sets `Cookie` from its variable, so nginx refuses those vhosts
14
+ * (`nginx -t`) while the map is missing: it is installed before the panel
15
+ * first writes one — on a fresh install with nginx's own configuration, on
16
+ * a redeploy before the panel restarts and re-renders the vhosts it finds.
17
+ */
18
+
19
+ const OWNER = Object.freeze({ mode: 0o644, uid: 0, gid: 0 });
20
+
21
+ /** The installed map's text, or null when there is none; never through a symlink. */
22
+ async function readInstalled() {
23
+ let fh;
24
+ try {
25
+ fh = await open(BACKEND_COOKIE_MAP_FILE, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ } catch (err) {
27
+ if (err.code === 'ENOENT') return null;
28
+ throw err;
29
+ }
30
+ try {
31
+ return await fh.readFile('utf8');
32
+ } finally {
33
+ await fh.close();
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Install or refresh the map as {@link BACKEND_COOKIE_MAP_FILE} — root-owned
39
+ * 0644, written atomically and never through a symlink — then test nginx
40
+ * and reload it. Idempotent. On a failed `nginx -t` the previous file (or
41
+ * its absence) is restored and the error thrown: without the map no tunnel
42
+ * vhost can be written, so the caller stops. Run as root.
43
+ *
44
+ * @param {{ reload?: boolean }} [options] - reload nginx afterwards (default true)
45
+ * @returns {Promise<{ changed: boolean }>}
46
+ */
47
+ export async function ensureBackendCookieMap({ reload = true } = {}) {
48
+ const content = renderBackendCookieMap();
49
+ const previous = await readInstalled();
50
+ if (previous === content) return { changed: false };
51
+
52
+ await mkdir(path.dirname(BACKEND_COOKIE_MAP_FILE), { recursive: true, mode: 0o755 });
53
+ await writeFileNoFollow(BACKEND_COOKIE_MAP_FILE, content, OWNER);
54
+ try {
55
+ await execa('nginx', ['-t']);
56
+ } catch (err) {
57
+ if (previous === null) await rm(BACKEND_COOKIE_MAP_FILE, { force: true });
58
+ else await writeFileNoFollow(BACKEND_COOKIE_MAP_FILE, previous, OWNER);
59
+ throw new Error(
60
+ `nginx rejected the map that keeps Authelia's session cookie from tunnel backends; left as it was:\n${err.stderr || err.message}`,
61
+ );
62
+ }
63
+ if (reload) await execa('systemctl', ['reload', 'nginx']);
64
+ return { changed: true };
65
+ }
@@ -0,0 +1,208 @@
1
+ import { execa } from 'execa';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { writeFileNoFollow } from './ownership.js';
5
+
6
+ /** Site name of the IP-based panel vhost (`https://<ip>:9292`). */
7
+ export const IP_PANEL_SITE = 'lamalibre-lamaste-panel-ip';
8
+
9
+ const AVAILABLE = `/etc/nginx/sites-available/${IP_PANEL_SITE}`;
10
+
11
+ /**
12
+ * Request headers every location proxying to the panel sets: a relay
13
+ * assertion reaches the panel only from a plugin's user surface on a tunnel
14
+ * vhost, set from Gatekeeper's answer — never one a client sent here.
15
+ */
16
+ const ASSERTION_CLEARED = `
17
+ # A relay assertion comes only from Gatekeeper, on a plugin's user surface
18
+ proxy_set_header X-Lamalibre-Relay-Assertion "";`;
19
+
20
+ /**
21
+ * The IP-based panel vhost: mTLS on 9292 with the self-signed certificate
22
+ * create-lamaste made for the relay's address. The panel disables it while
23
+ * its 2FA is on (`sites-enabled` link only). Pure.
24
+ *
25
+ * @param {string} pkiDir
26
+ * @returns {string}
27
+ */
28
+ export function generateIpPanelVhost(pkiDir) {
29
+ return `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
30
+ limit_req_zone $binary_remote_addr zone=enroll:1m rate=5r/m;
31
+
32
+ map $http_upgrade $connection_upgrade {
33
+ default upgrade;
34
+ '' close;
35
+ }
36
+
37
+ server {
38
+ listen 9292 ssl;
39
+ server_name _;
40
+
41
+ ssl_certificate ${pkiDir}/self-signed.pem;
42
+ ssl_certificate_key ${pkiDir}/self-signed-key.pem;
43
+
44
+ include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
45
+
46
+ # SSL settings
47
+ ssl_protocols TLSv1.2 TLSv1.3;
48
+ ssl_ciphers HIGH:!aNULL:!MD5;
49
+ ssl_prefer_server_ciphers on;
50
+
51
+ # Show certificate help page when client cert is missing or invalid
52
+ error_page 495 496 /cert-help.html;
53
+ location = /cert-help.html {
54
+ root /opt/lamalibre/lamaste/lamaste-server-ui;
55
+ internal;
56
+ }
57
+
58
+ # Proxy to serverd (mTLS required — reject if cert missing or invalid)
59
+ location / {
60
+ if ($ssl_client_verify != SUCCESS) {
61
+ return 496;
62
+ }
63
+ proxy_pass http://127.0.0.1:3100;
64
+
65
+ # Client cert headers — set from nginx TLS variables, never passed through from client
66
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
67
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
68
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
69
+
70
+ # Strip Authelia identity headers — not trusted on mTLS vhost
71
+ proxy_set_header Remote-User "";
72
+ proxy_set_header Remote-Groups "";
73
+ proxy_set_header Remote-Name "";
74
+ proxy_set_header Remote-Email "";${ASSERTION_CLEARED}
75
+
76
+ # Standard proxy headers
77
+ proxy_set_header Host $host;
78
+ proxy_set_header X-Real-IP $remote_addr;
79
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
80
+ proxy_set_header X-Forwarded-Proto $scheme;
81
+ }
82
+
83
+ # Public API paths (no mTLS verification check).
84
+ # ssl_verify_client is 'optional' at server level, so TLS handshake
85
+ # succeeds without a cert. These locations skip the $ssl_client_verify
86
+ # check and clear cert headers so the backend sees no client identity.
87
+ location /api/enroll {
88
+ limit_req zone=enroll burst=5 nodelay;
89
+ proxy_pass http://127.0.0.1:3100;
90
+ proxy_http_version 1.1;
91
+
92
+ # Clear cert headers so the backend sees no client cert
93
+ proxy_set_header X-SSL-Client-Verify "";
94
+ proxy_set_header X-SSL-Client-DN "";
95
+ proxy_set_header X-SSL-Client-Serial "";
96
+
97
+ # Strip Authelia identity headers — not trusted on public endpoint
98
+ proxy_set_header Remote-User "";
99
+ proxy_set_header Remote-Groups "";
100
+ proxy_set_header Remote-Name "";
101
+ proxy_set_header Remote-Email "";${ASSERTION_CLEARED}
102
+
103
+ proxy_set_header Host $host;
104
+ proxy_set_header X-Real-IP $remote_addr;
105
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
106
+ proxy_set_header X-Forwarded-Proto $scheme;
107
+ }
108
+
109
+ location /api/invite {
110
+ proxy_pass http://127.0.0.1:3100;
111
+ proxy_http_version 1.1;
112
+
113
+ proxy_set_header X-SSL-Client-Verify "";
114
+ proxy_set_header X-SSL-Client-DN "";
115
+ proxy_set_header X-SSL-Client-Serial "";
116
+
117
+ # Strip Authelia identity headers — not trusted on public endpoint
118
+ proxy_set_header Remote-User "";
119
+ proxy_set_header Remote-Groups "";
120
+ proxy_set_header Remote-Name "";
121
+ proxy_set_header Remote-Email "";${ASSERTION_CLEARED}
122
+
123
+ proxy_set_header Host $host;
124
+ proxy_set_header X-Real-IP $remote_addr;
125
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
126
+ proxy_set_header X-Forwarded-Proto $scheme;
127
+ }
128
+
129
+ # API paths with WebSocket upgrade support (mTLS required)
130
+ location /api {
131
+ if ($ssl_client_verify != SUCCESS) {
132
+ return 496;
133
+ }
134
+ proxy_pass http://127.0.0.1:3100;
135
+ proxy_http_version 1.1;
136
+
137
+ # Client cert headers — set from nginx TLS variables, never passed through from client
138
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
139
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
140
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
141
+
142
+ # Strip Authelia identity headers — not trusted on mTLS vhost
143
+ proxy_set_header Remote-User "";
144
+ proxy_set_header Remote-Groups "";
145
+ proxy_set_header Remote-Name "";
146
+ proxy_set_header Remote-Email "";${ASSERTION_CLEARED}
147
+
148
+ # Standard proxy headers
149
+ proxy_set_header Host $host;
150
+ proxy_set_header X-Real-IP $remote_addr;
151
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
152
+ proxy_set_header X-Forwarded-Proto $scheme;
153
+
154
+ # WebSocket: only upgrade when client requests it
155
+ proxy_set_header Upgrade $http_upgrade;
156
+ proxy_set_header Connection $connection_upgrade;
157
+ }
158
+ }
159
+ `;
160
+ }
161
+
162
+ /** The installed vhost's text, or null when there is none; never through a symlink. */
163
+ async function readInstalled() {
164
+ let fh;
165
+ try {
166
+ fh = await open(AVAILABLE, constants.O_RDONLY | constants.O_NOFOLLOW);
167
+ } catch (err) {
168
+ if (err.code === 'ENOENT') return null;
169
+ throw err;
170
+ }
171
+ try {
172
+ return await fh.readFile('utf8');
173
+ } finally {
174
+ await fh.close();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Bring an installed IP-based panel vhost in line with what
180
+ * {@link generateIpPanelVhost} renders now — the redeploy path; a fresh
181
+ * install writes it itself. Only its text changes: its `sites-enabled` link
182
+ * (the panel's 2FA unlinks it) stays as it is, and a relay without the vhost
183
+ * gets none. Written atomically as root, never through a symlink; on a
184
+ * failed `nginx -t` the previous text is restored and the error thrown.
185
+ * Run as root.
186
+ *
187
+ * @param {string} pkiDir
188
+ * @param {{ reload?: boolean }} [options] - reload nginx afterwards (default true)
189
+ * @returns {Promise<{ changed: boolean }>}
190
+ */
191
+ export async function refreshIpPanelVhost(pkiDir, { reload = true } = {}) {
192
+ const previous = await readInstalled();
193
+ const content = generateIpPanelVhost(pkiDir);
194
+ if (previous === null || previous === content) return { changed: false };
195
+
196
+ const owner = { mode: 0o644, uid: 0, gid: 0 };
197
+ await writeFileNoFollow(AVAILABLE, content, owner);
198
+ try {
199
+ await execa('nginx', ['-t']);
200
+ } catch (err) {
201
+ await writeFileNoFollow(AVAILABLE, previous, owner);
202
+ throw new Error(
203
+ `nginx rejected the refreshed IP panel vhost; left as it was:\n${err.stderr || err.message}`,
204
+ );
205
+ }
206
+ if (reload) await execa('systemctl', ['reload', 'nginx']);
207
+ return { changed: true };
208
+ }
@@ -111,8 +111,10 @@ lamaste ALL=(root) NOPASSWD: /usr/sbin/nginx -t
111
111
  # --- root-owned programs that validate their own arguments ---
112
112
  # lamaste-priv: install/enable/remove the panel's nginx vhosts (every directive
113
113
  # checked against an allow-list — nginx opens the files a vhost names as
114
- # root), store a TOTP secret with Authelia's CLI as the Authelia user, and
115
- # start a self-update (create-lamaste@<version>) in a transient unit.
114
+ # root); with Authelia's CLI as the Authelia user, store a TOTP secret, delete
115
+ # a user's second factors and check for a user's OIDC opaque identifier
116
+ # (usernames validated); and start a self-update (create-lamaste@<version>)
117
+ # in a transient unit.
116
118
  lamaste ALL=(root) NOPASSWD: /usr/local/sbin/lamaste-priv
117
119
  # Let's Encrypt: certbot and openssl with fixed flags, hostnames and email
118
120
  # validated. See scripts/lamaste-certbot, scripts/lamaste-cert-info.
@@ -203,7 +205,7 @@ export const AUTHELIA_OIDC_KEY_PATH = `${AUTHELIA_CONFIG_DIR}/oidc-issuer.pem`;
203
205
  * Authelia's configuration is rendered as a Go template: that is how it
204
206
  * reads the OIDC issuer key (`{{ secret "…" }}`) without the panel, which
205
207
  * writes the configuration, ever holding the key. Set on the unit and on
206
- * every `authelia` CLI invocation (lamaste-priv authelia-totp).
208
+ * every `authelia` CLI invocation (lamaste-priv authelia-totp, authelia-user).
207
209
  */
208
210
  export const AUTHELIA_CONFIG_FILTERS_ENV = 'X_AUTHELIA_CONFIG_FILTERS=template';
209
211
  export const AUTHELIA_LOG_DIR = '/var/log/authelia';