@lamalibre/create-lamaste 4.4.0 → 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.
- package/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- 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 +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- 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 +5 -5
- 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/managing-users.md +113 -25
- 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 +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +83 -189
- 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 +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.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 +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugin-packages.js +30 -22
- package/vendor/serverd/src/lib/plugins.js +94 -15
- package/vendor/serverd/src/lib/registry-credential.js +46 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamalibre/create-lamaste",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
46
|
+
"@lamalibre/lamaste": "^4.5.0",
|
|
47
47
|
"chalk": "^5.3.0",
|
|
48
48
|
"execa": "^9.0.0",
|
|
49
49
|
"listr2": "^8.0.0"
|
package/scripts/lamaste-priv
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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 ${
|
|
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)
|
|
115
|
-
#
|
|
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';
|