@lamalibre/create-lamaste 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +4 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +6 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +99 -35
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +34 -16
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +32 -3
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +190 -97
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +18 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/plugins.js +1067 -161
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +36 -6
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +33 -16
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +306 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/storage.js +10 -2
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +0 -257
- 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.
|
|
3
|
+
"version": "4.3.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.
|
|
46
|
+
"@lamalibre/lamaste": "^4.3.0",
|
|
47
47
|
"chalk": "^5.3.0",
|
|
48
48
|
"execa": "^9.0.0",
|
|
49
49
|
"listr2": "^8.0.0"
|
package/scripts/lamaste-priv
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
@@ -85,7 +85,7 @@ ${b('SYSTEM MODIFICATIONS')}
|
|
|
85
85
|
• Hardens SSH (disables password authentication)
|
|
86
86
|
|
|
87
87
|
${y('Packages')}
|
|
88
|
-
• Installs Node.js
|
|
88
|
+
• Installs Node.js 22, nginx, certbot
|
|
89
89
|
|
|
90
90
|
${y('Certificates')}
|
|
91
91
|
• Generates mTLS CA, server, and client certificates
|
|
@@ -330,7 +330,7 @@ ${chalk.cyan.bold('│')} The following changes will be made:
|
|
|
330
330
|
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
331
331
|
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Reset UFW firewall (allow ports 22, 80, 443, 9292) ${chalk.cyan.bold('│')}
|
|
332
332
|
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Harden SSH (disable password authentication) ${chalk.cyan.bold('│')}
|
|
333
|
-
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js
|
|
333
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js 22, nginx, certbot ${chalk.cyan.bold('│')}
|
|
334
334
|
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Generate mTLS certificates for browser access ${chalk.cyan.bold('│')}
|
|
335
335
|
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Create lamaste user and systemd service ${chalk.cyan.bold('│')}
|
|
336
336
|
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Deploy panel to /opt/lamalibre/lamaste/ ${chalk.cyan.bold('│')}
|
|
@@ -556,7 +556,7 @@ export async function main() {
|
|
|
556
556
|
// nginx, certbot and openssl. Its hardening subtasks (UFW, fail2ban,
|
|
557
557
|
// SSH) skip themselves — the same as the interactive install.
|
|
558
558
|
{ key: 'harden_system', title: 'Hardening operating system', fn: hardenTasks },
|
|
559
|
-
{ key: 'install_node', title: 'Installing Node.js
|
|
559
|
+
{ key: 'install_node', title: 'Installing Node.js 22', fn: nodeTasks },
|
|
560
560
|
{ key: 'generate_certs', title: 'Generating mTLS certificates', fn: mtlsTasks },
|
|
561
561
|
{ key: 'configure_nginx', title: 'Configuring nginx', fn: nginxTasks },
|
|
562
562
|
{ key: 'deploy_panel', title: 'Deploying Lamaste panel', fn: panelTasks },
|
|
@@ -574,7 +574,7 @@ export async function main() {
|
|
|
574
574
|
task: (_ctx, task) => hardenTasks(ctx, task),
|
|
575
575
|
},
|
|
576
576
|
{
|
|
577
|
-
title: 'Installing Node.js
|
|
577
|
+
title: 'Installing Node.js 22',
|
|
578
578
|
task: (_ctx, task) => nodeTasks(ctx, task),
|
|
579
579
|
},
|
|
580
580
|
{
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
package/src/tasks/gatekeeper.js
CHANGED
|
@@ -130,7 +130,12 @@ export function gatekeeperTasks(ctx, task) {
|
|
|
130
130
|
// immediately. Successful auth (200) is cached for 10s — matching
|
|
131
131
|
// the Cache-Control max-age Gatekeeper sets on the response. Omitting
|
|
132
132
|
// `proxy_ignore_headers Cache-Control` means nginx will honor the
|
|
133
|
-
// upstream max-age as a further shortening signal.
|
|
133
|
+
// upstream max-age as a further shortening signal. The key names the
|
|
134
|
+
// vhost by `$server_name`, never `$http_host`: an absolute-form request
|
|
135
|
+
// line selects one vhost while the client's Host header names another,
|
|
136
|
+
// and a decision cached for that other tunnel would be replayed here.
|
|
137
|
+
// The hostname comes first and `|` separates it: a hostname cannot
|
|
138
|
+
// contain `|`, so no other session and hostname make the same key.
|
|
134
139
|
const cacheConfig = `# Lamaste Gatekeeper auth response cache zone
|
|
135
140
|
# Scope: http{}. Included from nginx.conf.
|
|
136
141
|
proxy_cache_path /var/cache/nginx/authz levels=1:2
|
|
@@ -139,9 +144,9 @@ proxy_cache_path /var/cache/nginx/authz levels=1:2
|
|
|
139
144
|
inactive=5m;
|
|
140
145
|
`;
|
|
141
146
|
const cacheLocConfig = `# Lamaste Gatekeeper auth_request cache policy
|
|
142
|
-
# Scope: location{}
|
|
147
|
+
# Scope: the location{} a tunnel vhost's auth_request targets.
|
|
143
148
|
proxy_cache lamaste_authz;
|
|
144
|
-
proxy_cache_key "$cookie_authelia_session
|
|
149
|
+
proxy_cache_key "$server_name|$cookie_authelia_session";
|
|
145
150
|
proxy_cache_valid 200 10s;
|
|
146
151
|
proxy_cache_valid 403 0;
|
|
147
152
|
proxy_cache_use_stale off;
|