@lamalibre/create-lamaste 4.2.0 → 4.3.1

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 (128) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/scripts/lamaste-priv +205 -2
  4. package/src/index.js +8 -4
  5. package/src/lib/node-version.js +114 -0
  6. package/src/lib/panel-pin.js +41 -0
  7. package/src/lib/relay-services.js +15 -1
  8. package/src/lib/service-config.js +113 -0
  9. package/src/tasks/gatekeeper.js +8 -3
  10. package/src/tasks/node.js +34 -75
  11. package/src/tasks/redeploy.js +36 -0
  12. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  13. package/vendor/docs/00-introduction/quickstart.md +5 -5
  14. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  15. package/vendor/docs/01-concepts/authentication.md +6 -3
  16. package/vendor/docs/01-concepts/authorization.md +29 -17
  17. package/vendor/docs/01-concepts/certificates.md +3 -3
  18. package/vendor/docs/01-concepts/mtls.md +2 -2
  19. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  20. package/vendor/docs/01-concepts/security-model.md +110 -35
  21. package/vendor/docs/01-concepts/tickets.md +2 -2
  22. package/vendor/docs/01-concepts/tunneling.md +4 -4
  23. package/vendor/docs/02-guides/agent-setup.md +34 -16
  24. package/vendor/docs/02-guides/certificate-management.md +2 -2
  25. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  26. package/vendor/docs/02-guides/desktop-app-setup.md +11 -10
  27. package/vendor/docs/02-guides/installation.md +3 -3
  28. package/vendor/docs/02-guides/onboarding.md +2 -2
  29. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  34. package/vendor/docs/03-architecture/overview.md +8 -8
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +2 -2
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  40. package/vendor/docs/04-api-reference/app-host.md +360 -0
  41. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  42. package/vendor/docs/04-api-reference/certificates.md +95 -38
  43. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  44. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  45. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  46. package/vendor/docs/04-api-reference/overview.md +137 -111
  47. package/vendor/docs/04-api-reference/registry-and-oidc.md +8 -4
  48. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  49. package/vendor/docs/05-operations/backup-and-restore.md +3 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +25 -20
  52. package/vendor/docs/06-reference/config-files.md +195 -97
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  57. package/vendor/docs/_index.json +20 -0
  58. package/vendor/docs/gatekeeper-design.md +22 -19
  59. package/vendor/docs/identity-directory-design.md +57 -56
  60. package/vendor/docs/package.json +1 -1
  61. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  62. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  63. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  64. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  65. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  66. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/index.js +47 -8
  68. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  71. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  72. package/vendor/gatekeeper/package.json +4 -3
  73. package/vendor/server/bin/lamaste-server.js +25 -1
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/audit.js +60 -0
  76. package/vendor/server/src/commands/panel-pin.js +40 -0
  77. package/vendor/server/src/commands/plugins.js +963 -12
  78. package/vendor/server/src/commands/push-install.js +317 -0
  79. package/vendor/server/src/commands/registry-credential.js +2 -1
  80. package/vendor/server/src/commands/uninstall.js +28 -1
  81. package/vendor/server/src/ndjson.js +4 -2
  82. package/vendor/server/src/panel-api.js +277 -34
  83. package/vendor/server/src/panel-identity.js +165 -0
  84. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
  85. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  86. package/vendor/server-ui/dist/index.html +2 -2
  87. package/vendor/serverd/package.json +2 -2
  88. package/vendor/serverd/src/index.js +56 -4
  89. package/vendor/serverd/src/lib/actor.js +24 -0
  90. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  91. package/vendor/serverd/src/lib/app-error.js +10 -0
  92. package/vendor/serverd/src/lib/audit.js +145 -0
  93. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  94. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  95. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  96. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  97. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  98. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  99. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  100. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  101. package/vendor/serverd/src/lib/mtls.js +89 -8
  102. package/vendor/serverd/src/lib/nginx.js +298 -44
  103. package/vendor/serverd/src/lib/notifications.js +107 -0
  104. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  105. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  106. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  107. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  108. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  109. package/vendor/serverd/src/lib/plugins.js +298 -68
  110. package/vendor/serverd/src/lib/push-install.js +88 -2
  111. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  112. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  113. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  114. package/vendor/serverd/src/lib/retained-data.js +99 -0
  115. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  116. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  117. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  118. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  119. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  120. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  121. package/vendor/serverd/src/routes/management/agents.js +262 -1
  122. package/vendor/serverd/src/routes/management/audit.js +81 -0
  123. package/vendor/serverd/src/routes/management/certs.js +1 -15
  124. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  125. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  126. package/vendor/serverd/src/routes/management.js +4 -0
  127. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  128. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-lamaste",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
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.2.0",
46
+ "@lamalibre/lamaste": "^4.3.1",
47
47
  "chalk": "^5.3.0",
48
48
  "execa": "^9.0.0",
49
49
  "listr2": "^8.0.0"
@@ -12,6 +12,7 @@ import { existsSync } from 'node:fs';
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { assertProductionBundle } from '../../../../scripts/assert-production-bundle.js';
15
16
 
16
17
  const thisDir = dirname(fileURLToPath(import.meta.url));
17
18
  const packageRoot = join(thisDir, '..');
@@ -61,6 +62,8 @@ async function main() {
61
62
  );
62
63
  }
63
64
 
65
+ await assertProductionBundle(clientDist, { label: 'lamaste-server-ui/dist', monorepoRoot });
66
+
64
67
  const clientDest = join(vendorDir, 'server-ui');
65
68
  await mkdir(join(clientDest, 'dist'), { recursive: true });
66
69
  await cp(clientDist, join(clientDest, 'dist'), { recursive: true });
@@ -70,15 +73,17 @@ async function main() {
70
73
  // --- gatekeeper: package.json + dist/ (compiled TypeScript) ---
71
74
  const gatekeeperSrc = join(monorepoRoot, 'packages', 'sdks', 'gatekeeper');
72
75
  const gatekeeperDist = join(gatekeeperSrc, 'dist');
73
- if (existsSync(gatekeeperDist)) {
74
- const gatekeeperDest = join(vendorDir, 'gatekeeper');
75
- await mkdir(gatekeeperDest, { recursive: true });
76
- await cp(join(gatekeeperSrc, 'package.json'), join(gatekeeperDest, 'package.json'));
77
- await cp(gatekeeperDist, join(gatekeeperDest, 'dist'), { recursive: true });
78
- console.error('Bundled vendor/gatekeeper (package.json + dist/)');
79
- } else {
80
- console.error('Skipped vendor/gatekeeper (dist/ not found — run "npm run build" first)');
76
+ // An installer without Gatekeeper cannot deploy it: refuse, never skip.
77
+ if (!existsSync(gatekeeperDist)) {
78
+ throw new Error(
79
+ `lamaste-gatekeeper/dist/ not found at ${gatekeeperDist}. Run "npm run build" first.`,
80
+ );
81
81
  }
82
+ const gatekeeperDest = join(vendorDir, 'gatekeeper');
83
+ await mkdir(gatekeeperDest, { recursive: true });
84
+ await cp(join(gatekeeperSrc, 'package.json'), join(gatekeeperDest, 'package.json'));
85
+ await cp(gatekeeperDist, join(gatekeeperDest, 'dist'), { recursive: true });
86
+ console.error('Bundled vendor/gatekeeper (package.json + dist/)');
82
87
 
83
88
  // --- docs: version-stamped markdown ---
84
89
  const docsStampScript = join(
@@ -17,6 +17,11 @@
17
17
  * nginx-site remove <name> delete <name>, its link and its backup
18
18
  * authelia-totp <username> stdin: base32 secret
19
19
  * self-update <version> run create-lamaste@<version> detached
20
+ * hooks install <route> <sha256|none> stdin: the plugin's approved relay hook
21
+ * module (none: shared hooks only); start its runner
22
+ * hooks activate <route> <sha256> point the runner back at a module kept from before
23
+ * hooks restart <route> stop the plugin's hook runner (the socket stays)
24
+ * hooks remove <route> disable the runner and delete its modules
20
25
  *
21
26
  * Why vhosts are validated: nginx's master process runs as root and opens the
22
27
  * files a configuration names — `access_log /etc/cron.d/x` with a crafted
@@ -484,7 +489,7 @@ function isRegularFile(p) {
484
489
  }
485
490
  }
486
491
 
487
- function readStdin(maxBytes) {
492
+ function readStdinBytes(maxBytes) {
488
493
  const chunks = [];
489
494
  let total = 0;
490
495
  const buf = Buffer.alloc(65536);
@@ -502,7 +507,11 @@ function readStdin(maxBytes) {
502
507
  if (total > maxBytes) throw new UsageError('input is too large');
503
508
  chunks.push(Buffer.from(buf.subarray(0, read)));
504
509
  }
505
- return Buffer.concat(chunks).toString('utf8');
510
+ return Buffer.concat(chunks);
511
+ }
512
+
513
+ function readStdin(maxBytes) {
514
+ return readStdinBytes(maxBytes).toString('utf8');
506
515
  }
507
516
 
508
517
  // ---------------------------------------------------------------------------
@@ -668,6 +677,193 @@ function selfUpdate(version) {
668
677
  process.stdout.write(`${unit}\n`);
669
678
  }
670
679
 
680
+ // ---------------------------------------------------------------------------
681
+ // Relay hook runners
682
+ // ---------------------------------------------------------------------------
683
+
684
+ const HOOKS_DIR = '/opt/lamalibre/lamaste/hooks';
685
+ const HOOKS_UNIT_PREFIX = 'lamalibre-lamaste-hooks';
686
+ const SYSTEMCTL = '/usr/bin/systemctl';
687
+ const HOOK_ROUTE_RE = /^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$/;
688
+ const SHA256_RE = /^[a-f0-9]{64}$/;
689
+ const MAX_HOOK_MODULE_BYTES = 4 * 1024 * 1024;
690
+
691
+ function assertHookRoute(route) {
692
+ if (typeof route !== 'string' || !HOOK_ROUTE_RE.test(route)) {
693
+ throw new UsageError('invalid plugin route');
694
+ }
695
+ }
696
+
697
+ function assertSha256(digest) {
698
+ if (typeof digest !== 'string' || !SHA256_RE.test(digest)) {
699
+ throw new UsageError('digest must be a SHA-256 hex string');
700
+ }
701
+ }
702
+
703
+ /** Create `dir` (0755, root) unless it exists; refuse anything but a root-owned directory. */
704
+ function ensureRootDirectory(dir) {
705
+ try {
706
+ fs.mkdirSync(dir, { mode: 0o755 });
707
+ } catch (err) {
708
+ if (err.code !== 'EEXIST') throw err;
709
+ }
710
+ const st = fs.lstatSync(dir);
711
+ if (!st.isDirectory() || st.uid !== 0) {
712
+ throw new Error(`${dir} is not a root-owned directory`);
713
+ }
714
+ }
715
+
716
+ function sha256(bytes) {
717
+ return crypto.createHash('sha256').update(bytes).digest('hex');
718
+ }
719
+
720
+ function systemctl(args, { allowFailure = false } = {}) {
721
+ const result = spawnSync(SYSTEMCTL, args, {
722
+ cwd: '/',
723
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin' },
724
+ encoding: 'utf8',
725
+ timeout: 30_000,
726
+ });
727
+ if (result.error) throw result.error;
728
+ if (result.status !== 0 && !allowFailure) {
729
+ throw new Error(
730
+ `systemctl ${args.join(' ')} exited with status ${result.status}: ${(result.stderr || '').trim()}`,
731
+ );
732
+ }
733
+ }
734
+
735
+ function runnerEnvPath(route) {
736
+ return path.join(HOOKS_DIR, route, 'runner.env');
737
+ }
738
+
739
+ /** The module the runner currently loads (from runner.env), or null. */
740
+ function activeHookModule(route) {
741
+ let text;
742
+ try {
743
+ text = fs.readFileSync(runnerEnvPath(route), 'utf8');
744
+ } catch (err) {
745
+ if (err.code === 'ENOENT') return null;
746
+ throw err;
747
+ }
748
+ const m = /^LAMASTE_HOOK_RUNNER_ARGS=--entry (\S+)$/m.exec(text);
749
+ return m ? m[1] : null;
750
+ }
751
+
752
+ /**
753
+ * runner.env: the node flag that lets the runner read the module, and the
754
+ * runner flag that names it. Both empty when the plugin uses shared hooks
755
+ * only. Paths are built here, never taken from the caller.
756
+ */
757
+ function writeRunnerEnv(route, modulePath) {
758
+ const nodeArgs = modulePath ? `--allow-fs-read=${modulePath}` : '';
759
+ const runnerArgs = modulePath ? `--entry ${modulePath}` : '';
760
+ atomicWrite(
761
+ runnerEnvPath(route),
762
+ `# Managed by lamaste-priv: the approved relay hook module of plugin ${route}.\n` +
763
+ `LAMASTE_HOOK_NODE_ARGS=${nodeArgs}\nLAMASTE_HOOK_RUNNER_ARGS=${runnerArgs}\n`,
764
+ 0o644,
765
+ );
766
+ }
767
+
768
+ /** Keep the active module and the one before it (for `hooks activate`); delete the rest. */
769
+ function pruneHookModules(route, keep) {
770
+ const dir = path.join(HOOKS_DIR, route);
771
+ for (const name of fs.readdirSync(dir)) {
772
+ if (!/^[a-f0-9]{64}\.mjs$/.test(name)) continue;
773
+ const file = path.join(dir, name);
774
+ if (!keep.includes(file)) unlinkIfPresent(file);
775
+ }
776
+ }
777
+
778
+ function restartRunner(route) {
779
+ // The socket stays; the next call starts a runner with the new module.
780
+ systemctl(['stop', `${HOOKS_UNIT_PREFIX}@${route}.service`]);
781
+ }
782
+
783
+ function hooksInstall(route, digest) {
784
+ assertHookRoute(route);
785
+ if (digest !== 'none') assertSha256(digest);
786
+ ensureRootDirectory(HOOKS_DIR);
787
+ ensureRootDirectory(path.join(HOOKS_DIR, route));
788
+ const previous = activeHookModule(route);
789
+
790
+ let modulePath = null;
791
+ if (digest !== 'none') {
792
+ const bytes = readStdinBytes(MAX_HOOK_MODULE_BYTES);
793
+ if (bytes.length === 0) throw new UsageError('the hook module arrives on stdin');
794
+ if (sha256(bytes) !== digest) {
795
+ throw new Error('the hook module does not match the approved digest');
796
+ }
797
+ modulePath = path.join(HOOKS_DIR, route, `${digest}.mjs`);
798
+ if (!isRegularFile(modulePath) || sha256(fs.readFileSync(modulePath)) !== digest) {
799
+ atomicWrite(modulePath, bytes, 0o644);
800
+ }
801
+ }
802
+ writeRunnerEnv(route, modulePath);
803
+ pruneHookModules(route, [modulePath, previous].filter(Boolean));
804
+ systemctl(['enable', '--now', `${HOOKS_UNIT_PREFIX}@${route}.socket`]);
805
+ restartRunner(route);
806
+ }
807
+
808
+ function hooksActivate(route, digest) {
809
+ assertHookRoute(route);
810
+ assertSha256(digest);
811
+ const modulePath = path.join(HOOKS_DIR, route, `${digest}.mjs`);
812
+ if (!isRegularFile(modulePath) || sha256(fs.readFileSync(modulePath)) !== digest) {
813
+ throw new Error('no kept hook module has that digest');
814
+ }
815
+ const previous = activeHookModule(route);
816
+ writeRunnerEnv(route, modulePath);
817
+ pruneHookModules(route, [modulePath, previous].filter(Boolean));
818
+ systemctl(['enable', '--now', `${HOOKS_UNIT_PREFIX}@${route}.socket`]);
819
+ restartRunner(route);
820
+ }
821
+
822
+ function hooksRestart(route) {
823
+ assertHookRoute(route);
824
+ restartRunner(route);
825
+ }
826
+
827
+ function hooksRemove(route) {
828
+ assertHookRoute(route);
829
+ systemctl(['disable', '--now', `${HOOKS_UNIT_PREFIX}@${route}.socket`], { allowFailure: true });
830
+ systemctl(['stop', `${HOOKS_UNIT_PREFIX}@${route}.service`], { allowFailure: true });
831
+ const dir = path.join(HOOKS_DIR, route);
832
+ let st = null;
833
+ try {
834
+ st = fs.lstatSync(dir);
835
+ } catch (err) {
836
+ if (err.code !== 'ENOENT') throw err;
837
+ }
838
+ if (st) {
839
+ if (!st.isDirectory() || st.uid !== 0) throw new Error(`${dir} is not a root-owned directory`);
840
+ fs.rmSync(dir, { recursive: true, force: true });
841
+ }
842
+ }
843
+
844
+ function hooks(action, rest) {
845
+ switch (action) {
846
+ case 'install':
847
+ if (rest.length !== 2) throw new UsageError('usage: hooks install <route> <sha256|none>');
848
+ hooksInstall(rest[0], rest[1]);
849
+ return;
850
+ case 'activate':
851
+ if (rest.length !== 2) throw new UsageError('usage: hooks activate <route> <sha256>');
852
+ hooksActivate(rest[0], rest[1]);
853
+ return;
854
+ case 'restart':
855
+ if (rest.length !== 1) throw new UsageError('usage: hooks restart <route>');
856
+ hooksRestart(rest[0]);
857
+ return;
858
+ case 'remove':
859
+ if (rest.length !== 1) throw new UsageError('usage: hooks remove <route>');
860
+ hooksRemove(rest[0]);
861
+ return;
862
+ default:
863
+ throw new UsageError(`unknown hooks action: ${action ?? '(none)'}`);
864
+ }
865
+ }
866
+
671
867
  function main(argv) {
672
868
  if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
673
869
  throw new UsageError('must run as root (via sudo)');
@@ -687,6 +883,10 @@ function main(argv) {
687
883
  if (rest.length !== 1) throw new UsageError('usage: self-update <version>');
688
884
  selfUpdate(rest[0]);
689
885
  return;
886
+ case 'hooks':
887
+ if (rest.length < 1) throw new UsageError('usage: hooks <action> <route> [...]');
888
+ hooks(rest[0], rest.slice(1));
889
+ return;
690
890
  default:
691
891
  throw new UsageError(`unknown operation: ${op ?? '(none)'}`);
692
892
  }
@@ -708,5 +908,8 @@ if (require.main === module) {
708
908
  PANEL_SITE_RE,
709
909
  UsageError,
710
910
  AUTHELIA_CLI_ENV,
911
+ assertHookRoute,
912
+ HOOK_ROUTE_RE,
913
+ main,
711
914
  };
712
915
  }
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import chalk from 'chalk';
14
14
  import { execa } from 'execa';
15
15
  import { detectOS, detectIP, checkRoot } from './lib/env.js';
16
16
  import { printSummary } from './lib/summary.js';
17
+ import { readPanelKeyPin } from './lib/panel-pin.js';
17
18
  import { hardenTasks } from './tasks/harden.js';
18
19
  import { nodeTasks } from './tasks/node.js';
19
20
  import { mtlsTasks } from './tasks/mtls.js';
@@ -85,7 +86,7 @@ ${b('SYSTEM MODIFICATIONS')}
85
86
  • Hardens SSH (disables password authentication)
86
87
 
87
88
  ${y('Packages')}
88
- • Installs Node.js 20, nginx, certbot
89
+ • Installs Node.js 22, nginx, certbot
89
90
 
90
91
  ${y('Certificates')}
91
92
  • Generates mTLS CA, server, and client certificates
@@ -330,7 +331,7 @@ ${chalk.cyan.bold('│')} The following changes will be made:
330
331
  ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
331
332
  ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Reset UFW firewall (allow ports 22, 80, 443, 9292) ${chalk.cyan.bold('│')}
332
333
  ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Harden SSH (disable password authentication) ${chalk.cyan.bold('│')}
333
- ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js 20, nginx, certbot ${chalk.cyan.bold('│')}
334
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js 22, nginx, certbot ${chalk.cyan.bold('│')}
334
335
  ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Generate mTLS certificates for browser access ${chalk.cyan.bold('│')}
335
336
  ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Create lamaste user and systemd service ${chalk.cyan.bold('│')}
336
337
  ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Deploy panel to /opt/lamalibre/lamaste/ ${chalk.cyan.bold('│')}
@@ -556,7 +557,7 @@ export async function main() {
556
557
  // nginx, certbot and openssl. Its hardening subtasks (UFW, fail2ban,
557
558
  // SSH) skip themselves — the same as the interactive install.
558
559
  { key: 'harden_system', title: 'Hardening operating system', fn: hardenTasks },
559
- { key: 'install_node', title: 'Installing Node.js 20', fn: nodeTasks },
560
+ { key: 'install_node', title: 'Installing Node.js 22', fn: nodeTasks },
560
561
  { key: 'generate_certs', title: 'Generating mTLS certificates', fn: mtlsTasks },
561
562
  { key: 'configure_nginx', title: 'Configuring nginx', fn: nginxTasks },
562
563
  { key: 'deploy_panel', title: 'Deploying Lamaste panel', fn: panelTasks },
@@ -574,7 +575,7 @@ export async function main() {
574
575
  task: (_ctx, task) => hardenTasks(ctx, task),
575
576
  },
576
577
  {
577
- title: 'Installing Node.js 20',
578
+ title: 'Installing Node.js 22',
578
579
  task: (_ctx, task) => nodeTasks(ctx, task),
579
580
  },
580
581
  {
@@ -630,6 +631,7 @@ export async function main() {
630
631
  }
631
632
 
632
633
  if (flags.json) {
634
+ const panelServerPubkeySha256 = await readPanelKeyPin(ctx.pkiDir);
633
635
  emitJson({
634
636
  event: 'complete',
635
637
  server: {
@@ -637,6 +639,8 @@ export async function main() {
637
639
  panelUrl: `https://${ctx.ip}:9292`,
638
640
  p12Path: `${ctx.pkiDir}/client.p12`,
639
641
  p12PasswordPath: `${ctx.pkiDir}/.p12-password`,
642
+ // The key pin of the panel's IP certificate, for the desktop app.
643
+ ...(panelServerPubkeySha256 ? { panelServerPubkeySha256 } : {}),
640
644
  },
641
645
  });
642
646
  } else {
@@ -0,0 +1,114 @@
1
+ import { execa } from 'execa';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ /** The Node.js line the relay runs, from NodeSource. */
7
+ export const NODE_MAJOR = 22;
8
+
9
+ /**
10
+ * The oldest Node.js the relay accepts: 22.5 brought `node:sqlite`
11
+ * (lamaste-serverd's state database) and 22.13 the stable permission model
12
+ * (`--permission`), which sandboxes the relay hook runner. NodeSource's 22.x
13
+ * line moves on its own, so the minor is checked, not only the major.
14
+ */
15
+ export const MIN_NODE_VERSION = '22.13.0';
16
+
17
+ /**
18
+ * lamaste-serverd's `engines` stops below 25: Node 25 adds network
19
+ * permissions to `--permission`, which the hook runner's unit does not grant.
20
+ */
21
+ export const MAX_NODE_MAJOR_EXCLUSIVE = 25;
22
+
23
+ /** The binary every Lamaste unit runs (`ExecStart=/usr/bin/node …`). */
24
+ export const NODE_BIN = '/usr/bin/node';
25
+
26
+ /** `v22.13.1` → [22, 13, 1]; null when it is not a version. */
27
+ function parseNodeVersion(version) {
28
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
29
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
30
+ }
31
+
32
+ /** Whether `version` is at least {@link MIN_NODE_VERSION} and below {@link MAX_NODE_MAJOR_EXCLUSIVE}. */
33
+ export function nodeVersionSupported(version) {
34
+ const v = parseNodeVersion(version);
35
+ const min = parseNodeVersion(MIN_NODE_VERSION);
36
+ if (!v || !min) return false;
37
+ if (v[0] >= MAX_NODE_MAJOR_EXCLUSIVE) return false;
38
+ for (let i = 0; i < 3; i++) {
39
+ if (v[i] !== min[i]) return v[i] > min[i];
40
+ }
41
+ return true;
42
+ }
43
+
44
+ /** Whether `version` is too new to be installed over (only an operator should downgrade). */
45
+ export function nodeVersionTooNew(version) {
46
+ const v = parseNodeVersion(version);
47
+ return v !== null && v[0] >= MAX_NODE_MAJOR_EXCLUSIVE;
48
+ }
49
+
50
+ /** The refusal of a {@link nodeVersionTooNew} Node.js. */
51
+ export function nodeTooNewError(version) {
52
+ return new Error(
53
+ `Node.js ${version} at ${NODE_BIN} is not supported: Lamaste needs ${MIN_NODE_VERSION} or later, below ${MAX_NODE_MAJOR_EXCLUSIVE}.`,
54
+ );
55
+ }
56
+
57
+ /** The version of {@link NODE_BIN}, or null when it is missing. */
58
+ export async function installedNodeVersion() {
59
+ try {
60
+ const { stdout } = await execa(NODE_BIN, ['--version']);
61
+ return stdout.trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Install or upgrade Node.js from NodeSource's {@link NODE_MAJOR}.x line.
69
+ *
70
+ * The setup script runs as root, so it is downloaded into a directory of
71
+ * its own (`mkdtemp`, 0700) — never a fixed path in the shared /tmp, where
72
+ * another user could have put a file or a link first — and the directory
73
+ * goes on every path.
74
+ *
75
+ * @param {(line: string) => void} report
76
+ * @param {{ exec?: typeof execa, tmpRoot?: string }} [options] - replaces the commands and the
77
+ * temporary directory's parent (tests)
78
+ */
79
+ export async function installNodeFromNodeSource(report, options = {}) {
80
+ const exec = options.exec ?? execa;
81
+ const dir = await mkdtemp(path.join(options.tmpRoot ?? os.tmpdir(), 'lamaste-nodesource-'));
82
+ const setupScript = path.join(dir, 'nodesource_setup.sh');
83
+ try {
84
+ report('Downloading the NodeSource setup script...');
85
+ try {
86
+ await exec('curl', [
87
+ '-fsSL',
88
+ `https://deb.nodesource.com/setup_${NODE_MAJOR}.x`,
89
+ '-o',
90
+ setupScript,
91
+ ]);
92
+ } catch (error) {
93
+ throw new Error(
94
+ `Failed to download the NodeSource setup script. Check your internet connection.\n${error.stderr || error.message}`,
95
+ );
96
+ }
97
+ report('Running the NodeSource setup...');
98
+ try {
99
+ await exec('bash', [setupScript]);
100
+ } catch (error) {
101
+ throw new Error(`NodeSource setup script failed.\n${error.stderr || error.message}`);
102
+ }
103
+ } finally {
104
+ await rm(dir, { recursive: true, force: true });
105
+ }
106
+ report('Running apt-get install nodejs...');
107
+ try {
108
+ await exec('apt-get', ['install', '-y', 'nodejs']);
109
+ } catch (error) {
110
+ throw new Error(
111
+ `Failed to install Node.js. Try running 'apt-get update' manually and retrying.\n${error.stderr || error.message}`,
112
+ );
113
+ }
114
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The key pin of the panel's IP certificate, for the installer's JSON
3
+ * `complete` event: the desktop app's local install stores it with the
4
+ * server and verifies `https://<ip>:9292` by it from its first call on —
5
+ * no trust on first use.
6
+ */
7
+
8
+ import { X509Certificate, createHash } from 'node:crypto';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+
12
+ /**
13
+ * Base64 SHA-256 of the certificate's SubjectPublicKeyInfo — curl's
14
+ * `--pinnedpubkey sha256//<pin>` form, the one agents and the desktop app
15
+ * store (`panelServerPubkeySha256`).
16
+ *
17
+ * @param {string | Buffer} certificatePem
18
+ * @returns {string}
19
+ */
20
+ export function panelKeyPin(certificatePem) {
21
+ const spki = new X509Certificate(certificatePem).publicKey.export({
22
+ type: 'spki',
23
+ format: 'der',
24
+ });
25
+ return createHash('sha256').update(spki).digest('base64');
26
+ }
27
+
28
+ /**
29
+ * The pin of `<pkiDir>/self-signed.pem` (written by `tasks/nginx.js`), or
30
+ * undefined when it cannot be read — the desktop then pins on first use.
31
+ *
32
+ * @param {string} pkiDir
33
+ * @returns {Promise<string | undefined>}
34
+ */
35
+ export async function readPanelKeyPin(pkiDir) {
36
+ try {
37
+ return panelKeyPin(await readFile(join(pkiDir, 'self-signed.pem')));
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
@@ -17,9 +17,14 @@ import {
17
17
  AUTHELIA_USER,
18
18
  CHISEL_GROUP,
19
19
  CHISEL_UNIT_PATH,
20
+ HOOKS_DIR,
21
+ HOOKS_SERVICE_UNIT_PATH,
22
+ HOOKS_SOCKET_UNIT_PATH,
20
23
  WEB_ROOT,
21
24
  generateAutheliaUnit,
22
25
  generateChiselUnit,
26
+ generateHooksServiceUnit,
27
+ generateHooksSocketUnit,
23
28
  } from './service-config.js';
24
29
 
25
30
  async function writeIfChanged(path, content) {
@@ -44,7 +49,7 @@ async function writeIfChanged(path, content) {
44
49
  * `systemctl daemon-reload` and restarts what changed. Onboarding enables
45
50
  * and starts chisel and Authelia on a fresh install.
46
51
  *
47
- * @param {{ configDir: string, pkiDir: string }} ctx
52
+ * @param {{ configDir: string, pkiDir: string, installDir: string }} ctx
48
53
  * @param {(line: string) => void} report
49
54
  * @returns {Promise<{ chiselChanged: boolean, autheliaChanged: boolean }>}
50
55
  * whether the binary or unit of each changed (a running service needs a
@@ -100,6 +105,15 @@ export async function provisionRelayServices(ctx, report) {
100
105
  const chiselUnitChanged = await writeIfChanged(CHISEL_UNIT_PATH, generateChiselUnit(ctx));
101
106
  const autheliaUnitChanged = await writeIfChanged(AUTHELIA_UNIT_PATH, generateAutheliaUnit());
102
107
 
108
+ // Relay hook runners: template units only. lamaste-priv enables an
109
+ // instance per plugin and places its approved module under HOOKS_DIR,
110
+ // which stays root's; a running runner picks a new unit up when it next
111
+ // starts (it exits when idle).
112
+ report('Writing the relay hook runner units...');
113
+ await ensureDirectory(HOOKS_DIR, { uid: 0, gid: 0, mode: 0o755 });
114
+ await writeIfChanged(HOOKS_SOCKET_UNIT_PATH, generateHooksSocketUnit());
115
+ await writeIfChanged(HOOKS_SERVICE_UNIT_PATH, generateHooksServiceUnit(ctx));
116
+
103
117
  return {
104
118
  chiselChanged: chisel.changed || chiselUnitChanged,
105
119
  autheliaChanged: authelia.changed || autheliaUnitChanged || autheliaDir.migrated,
@@ -301,6 +301,119 @@ WantedBy=multi-user.target
301
301
  `;
302
302
  }
303
303
 
304
+ /**
305
+ * Relay hook runners — one per plugin that brings relay lifecycle hooks
306
+ * (`lamalibre-lamaste-hooks@<route>`). The panel never imports plugin code;
307
+ * it calls the plugin's runner over a Unix socket, and the runner runs the
308
+ * plugin's approved hook module in the sandbox below. Template units, written
309
+ * by create-lamaste; `lamaste-priv hooks install|remove|restart <route>`
310
+ * enables an instance and places its approved module under
311
+ * {@link HOOKS_DIR}/<route>/ (root-owned). Must match the server subpath's
312
+ * `HOOK_RUNNER_SOCKET_DIR` in @lamalibre/lamaste.
313
+ */
314
+ export const HOOKS_UNIT_PREFIX = 'lamalibre-lamaste-hooks';
315
+ export const HOOKS_SOCKET_UNIT_PATH = `/etc/systemd/system/${HOOKS_UNIT_PREFIX}@.socket`;
316
+ export const HOOKS_SERVICE_UNIT_PATH = `/etc/systemd/system/${HOOKS_UNIT_PREFIX}@.service`;
317
+ export const HOOKS_SOCKET_DIR = '/run/lamalibre-lamaste-hooks';
318
+ export const HOOKS_DIR = '/opt/lamalibre/lamaste/hooks';
319
+
320
+ /**
321
+ * The hook runner's socket: created by systemd, reachable by the panel's
322
+ * group only. The first connection starts the runner (socket activation),
323
+ * which exits when idle.
324
+ *
325
+ * @returns {string}
326
+ */
327
+ export function generateHooksSocketUnit() {
328
+ return `[Unit]
329
+ Description=Lamaste relay hook runner socket (plugin %i)
330
+
331
+ [Socket]
332
+ ListenStream=${HOOKS_SOCKET_DIR}/%i.sock
333
+ SocketUser=root
334
+ SocketGroup=lamaste
335
+ SocketMode=0660
336
+ DirectoryMode=0755
337
+ RemoveOnStop=yes
338
+ Accept=no
339
+ Backlog=64
340
+
341
+ [Install]
342
+ WantedBy=sockets.target
343
+ `;
344
+ }
345
+
346
+ /**
347
+ * The hook runner: Lamaste's runner program and one plugin's approved hook
348
+ * module, in a sandbox — a dynamic user with nothing of the relay's, a
349
+ * read-only file system with the panel's state, Authelia, certificates and
350
+ * nginx out of reach, no network (Unix sockets only, its own network
351
+ * namespace), and Node's permission model allowing reads of the runner and
352
+ * the module only (no child processes, workers, addons or writes).
353
+ * Standard output and error are discarded so nothing a hook prints — it sees
354
+ * application data — reaches the journal. `runner.env`, written by
355
+ * lamaste-priv, names the module.
356
+ *
357
+ * @param {{ installDir: string }} ctx
358
+ * @returns {string}
359
+ */
360
+ export function generateHooksServiceUnit(ctx) {
361
+ const lib = `${ctx.installDir}/serverd/node_modules/@lamalibre/lamaste/dist/hooks`;
362
+ return `[Unit]
363
+ Description=Lamaste relay hook runner (plugin %i)
364
+ Requires=${HOOKS_UNIT_PREFIX}@%i.socket
365
+ After=${HOOKS_UNIT_PREFIX}@%i.socket
366
+ ConditionPathExists=${HOOKS_DIR}/%i/runner.env
367
+
368
+ [Service]
369
+ Type=simple
370
+ EnvironmentFile=${HOOKS_DIR}/%i/runner.env
371
+ ExecStart=/usr/bin/node --permission --allow-fs-read=${lib}/* --max-old-space-size=160 $LAMASTE_HOOK_NODE_ARGS ${lib}/runner.js --plugin %i --listen-fd 3 $LAMASTE_HOOK_RUNNER_ARGS
372
+ Restart=no
373
+ TimeoutStopSec=2
374
+ StandardOutput=null
375
+ StandardError=null
376
+
377
+ DynamicUser=yes
378
+ UMask=0077
379
+ NoNewPrivileges=yes
380
+ CapabilityBoundingSet=
381
+ AmbientCapabilities=
382
+ ProtectSystem=strict
383
+ ProtectHome=yes
384
+ PrivateTmp=yes
385
+ PrivateDevices=yes
386
+ PrivateIPC=yes
387
+ PrivateNetwork=yes
388
+ ProtectHostname=yes
389
+ ProtectClock=yes
390
+ ProtectKernelTunables=yes
391
+ ProtectKernelModules=yes
392
+ ProtectKernelLogs=yes
393
+ ProtectControlGroups=yes
394
+ ProtectProc=invisible
395
+ ProcSubset=pid
396
+ RestrictNamespaces=yes
397
+ RestrictRealtime=yes
398
+ RestrictSUIDSGID=yes
399
+ LockPersonality=yes
400
+ RemoveIPC=yes
401
+ RestrictAddressFamilies=AF_UNIX
402
+ IPAddressDeny=any
403
+ SystemCallArchitectures=native
404
+ SystemCallFilter=@system-service
405
+ SystemCallFilter=~@privileged
406
+ SystemCallErrorNumber=EPERM
407
+ TemporaryFileSystem=/run:ro /var:ro /srv:ro /mnt:ro /media:ro
408
+ InaccessiblePaths=-/etc/lamalibre -/etc/authelia -/etc/letsencrypt -/etc/nginx -/etc/ssh -/etc/sudoers.d -/etc/sudoers -/etc/shadow -/etc/gshadow
409
+ MemoryMax=256M
410
+ MemorySwapMax=0
411
+ CPUQuota=50%
412
+ TasksMax=32
413
+ LimitNOFILE=1024
414
+ `;
415
+ }
416
+
304
417
  /** sites-available name of the port-80 catch-all. */
305
418
  export const HTTP_REDIRECT_SITE = 'lamalibre-lamaste-http-redirect';
306
419