@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-lamaste",
3
- "version": "4.4.1",
3
+ "version": "4.5.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.5.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
@@ -583,22 +591,25 @@ function lookupId(flag, name) {
583
591
  return id;
584
592
  }
585
593
 
594
+ /** Same shape as the panel's username schema; a leading "-" would read as a flag. */
595
+ const AUTHELIA_USERNAME_RE = /^[a-z0-9_][a-z0-9_-]{1,31}$/;
596
+
597
+ function assertAutheliaUsername(username) {
598
+ if (typeof username !== 'string' || !AUTHELIA_USERNAME_RE.test(username)) {
599
+ throw new UsageError('invalid username');
600
+ }
601
+ }
602
+
586
603
  /**
587
604
  * Store a TOTP secret in Authelia's database with Authelia's own CLI, run as
588
605
  * the Authelia service user (the database is its file).
589
606
  */
590
607
  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
- }
608
+ assertAutheliaUsername(username);
595
609
  const secret = readStdin(1024).trim();
596
610
  if (!/^[A-Z2-7]{16,128}$/.test(secret)) throw new UsageError('invalid TOTP secret');
597
611
 
598
- const uid = lookupId('-u', AUTHELIA_USER);
599
- const gid = lookupId('-g', AUTHELIA_USER);
600
- const result = spawnSync(
601
- AUTHELIA_BIN,
612
+ const result = runAutheliaCli(
602
613
  [
603
614
  'storage',
604
615
  'user',
@@ -615,11 +626,35 @@ function autheliaTotp(username) {
615
626
  '6',
616
627
  '--period',
617
628
  '30',
618
- '--config',
619
- AUTHELIA_CONFIG,
620
- '--',
621
- username,
622
629
  ],
630
+ username,
631
+ );
632
+ if (result.status !== 0) {
633
+ // Authelia's error output does not echo the secret; its success output
634
+ // (a QR code / otpauth URI) does, so only failures are relayed.
635
+ throw new Error(
636
+ `authelia exited with status ${result.status}: ${(result.stderr || '').trim()}`,
637
+ );
638
+ }
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // Authelia's per-user storage
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * Run `authelia <args> --config <config> [-- <username>]` as the Authelia
647
+ * service account, with the environment the service has (its storage
648
+ * encryption key comes from the configuration).
649
+ * Opening the database as root would leave root-owned journal files the
650
+ * service cannot write.
651
+ */
652
+ function runAutheliaCli(args, username) {
653
+ const uid = lookupId('-u', AUTHELIA_USER);
654
+ const gid = lookupId('-g', AUTHELIA_USER);
655
+ const result = spawnSync(
656
+ AUTHELIA_BIN,
657
+ [...args, '--config', AUTHELIA_CONFIG, ...(username === undefined ? [] : ['--', username])],
623
658
  {
624
659
  uid,
625
660
  gid,
@@ -630,13 +665,156 @@ function autheliaTotp(username) {
630
665
  },
631
666
  );
632
667
  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.
668
+ return result;
669
+ }
670
+
671
+ /** Everything Authelia printed, to match a known refusal in. */
672
+ function cliOutput(result) {
673
+ return `${result.stderr || ''}\n${result.stdout || ''}`;
674
+ }
675
+
676
+ /** Authelia's output with the username taken out: what this helper says may end up in a log. */
677
+ function redacted(text, username) {
678
+ return String(text || '')
679
+ .trim()
680
+ .split(username)
681
+ .join('<username>');
682
+ }
683
+
684
+ /**
685
+ * Delete the user's TOTP configuration and every WebAuthn credential from
686
+ * Authelia's storage. Authelia 4.39 refuses to delete a TOTP configuration
687
+ * that does not exist (`no TOTP configuration for user`), which here means
688
+ * there is nothing to delete; deleting WebAuthn credentials by username
689
+ * succeeds whatever number there are.
690
+ */
691
+ function autheliaPurgeSecondFactors(username) {
692
+ assertAutheliaUsername(username);
693
+ const totp = runAutheliaCli(['storage', 'user', 'totp', 'delete'], username);
694
+ if (totp.status !== 0 && !/no TOTP configuration for user/.test(cliOutput(totp))) {
636
695
  throw new Error(
637
- `authelia exited with status ${result.status}: ${(result.stderr || '').trim()}`,
696
+ `authelia storage user totp delete exited with status ${totp.status}: ${redacted(totp.stderr, username)}`,
697
+ );
698
+ }
699
+ const webauthn = runAutheliaCli(['storage', 'user', 'webauthn', 'delete', '--all'], username);
700
+ if (webauthn.status !== 0) {
701
+ throw new Error(
702
+ `authelia storage user webauthn delete exited with status ${webauthn.status}: ${redacted(webauthn.stderr, username)}`,
703
+ );
704
+ }
705
+ }
706
+
707
+ /** A YAML scalar as yaml.v3 writes a username: plain, or quoted when it would read as another type. */
708
+ function yamlScalar(text) {
709
+ const value = text.trim();
710
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
711
+ try {
712
+ return JSON.parse(value);
713
+ } catch {
714
+ return null;
715
+ }
716
+ }
717
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
718
+ return value.slice(1, -1).split("''").join("'");
719
+ }
720
+ return value;
721
+ }
722
+
723
+ /**
724
+ * Whether the YAML of `authelia storage user identifiers export` (a list of
725
+ * `{ service, sector_id, username, identifier }` under `identifiers`) names
726
+ * `username`.
727
+ */
728
+ function exportNamesUsername(text, username) {
729
+ for (const line of String(text).split('\n')) {
730
+ const match = /^\s*(?:-\s+)?username:(.*)$/.exec(line);
731
+ if (match && yamlScalar(match[1]) === username) return true;
732
+ }
733
+ return false;
734
+ }
735
+
736
+ /** Overwrite a file with zeros, flush it, and remove it. */
737
+ function shred(file) {
738
+ let fd = null;
739
+ try {
740
+ fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
741
+ const { size } = fs.fstatSync(fd);
742
+ const zeros = Buffer.alloc(Math.min(size, 65536));
743
+ for (let written = 0; written < size; written += zeros.length) {
744
+ fs.writeSync(fd, zeros, 0, Math.min(zeros.length, size - written), written);
745
+ }
746
+ fs.fsyncSync(fd);
747
+ } catch (err) {
748
+ if (err.code !== 'ENOENT') throw err;
749
+ } finally {
750
+ if (fd !== null) fs.closeSync(fd);
751
+ }
752
+ unlinkIfPresent(file);
753
+ }
754
+
755
+ /** The largest export read back: well beyond any opaque identifier table Lamaste serves. */
756
+ const MAX_IDENTIFIERS_EXPORT_BYTES = 32 * 1024 * 1024;
757
+
758
+ /**
759
+ * Whether Authelia's storage holds an OpenID Connect opaque identifier for
760
+ * the username. Authelia's CLI has no lookup by username, so it exports the
761
+ * table (as the Authelia account) into a directory only that account and
762
+ * root can enter, on /run (memory, not disk); the file is read, checked,
763
+ * overwritten and removed, whatever happens. Prints `yes` or `no`.
764
+ */
765
+ function autheliaHasOidcIdentifier(username) {
766
+ assertAutheliaUsername(username);
767
+ const uid = lookupId('-u', AUTHELIA_USER);
768
+ const gid = lookupId('-g', AUTHELIA_USER);
769
+ const dir = fs.mkdtempSync('/run/lamaste-priv-oidc-');
770
+ const file = path.join(dir, 'identifiers.yml');
771
+ try {
772
+ fs.chmodSync(dir, 0o700);
773
+ fs.chownSync(dir, uid, gid);
774
+ const result = runAutheliaCli(['storage', 'user', 'identifiers', 'export', '--file', file]);
775
+ if (result.status !== 0) {
776
+ // An empty table is not an export.
777
+ if (/no data to export/.test(cliOutput(result))) {
778
+ process.stdout.write('no\n');
779
+ return;
780
+ }
781
+ throw new Error(
782
+ `authelia storage user identifiers export exited with status ${result.status}: ${redacted(result.stderr, username)}`,
783
+ );
784
+ }
785
+ const st = fs.lstatSync(file);
786
+ if (!st.isFile() || st.uid !== uid)
787
+ throw new Error('the export is not the file Authelia wrote');
788
+ if (st.size > MAX_IDENTIFIERS_EXPORT_BYTES) throw new Error('the export is too large');
789
+ const named = exportNamesUsername(fs.readFileSync(file, 'utf8'), username);
790
+ process.stdout.write(named ? 'yes\n' : 'no\n');
791
+ } finally {
792
+ shred(file);
793
+ fs.rmSync(dir, { recursive: true, force: true });
794
+ }
795
+ }
796
+
797
+ const AUTHELIA_USER_ACTIONS = new Set(['purge-second-factors', 'has-oidc-identifier']);
798
+
799
+ /** `authelia-user <action> <username>`, checked before anything runs. */
800
+ function parseAutheliaUserArgs(rest) {
801
+ if (rest.length !== 2) {
802
+ throw new UsageError(
803
+ 'usage: authelia-user purge-second-factors|has-oidc-identifier <username>',
638
804
  );
639
805
  }
806
+ const [action, username] = rest;
807
+ if (!AUTHELIA_USER_ACTIONS.has(action)) {
808
+ throw new UsageError(`unknown authelia-user action: ${String(action).slice(0, 40)}`);
809
+ }
810
+ assertAutheliaUsername(username);
811
+ return { action, username };
812
+ }
813
+
814
+ function autheliaUser(rest) {
815
+ const { action, username } = parseAutheliaUserArgs(rest);
816
+ if (action === 'purge-second-factors') autheliaPurgeSecondFactors(username);
817
+ else autheliaHasOidcIdentifier(username);
640
818
  }
641
819
 
642
820
  /**
@@ -879,6 +1057,9 @@ function main(argv) {
879
1057
  if (rest.length !== 1) throw new UsageError('usage: authelia-totp <username>');
880
1058
  autheliaTotp(rest[0]);
881
1059
  return;
1060
+ case 'authelia-user':
1061
+ autheliaUser(rest);
1062
+ return;
882
1063
  case 'self-update':
883
1064
  if (rest.length !== 1) throw new UsageError('usage: self-update <version>');
884
1065
  selfUpdate(rest[0]);
@@ -910,6 +1091,9 @@ if (require.main === module) {
910
1091
  AUTHELIA_CLI_ENV,
911
1092
  assertHookRoute,
912
1093
  HOOK_ROUTE_RE,
1094
+ assertAutheliaUsername,
1095
+ parseAutheliaUserArgs,
1096
+ exportNamesUsername,
913
1097
  main,
914
1098
  };
915
1099
  }
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';