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