@lamalibre/create-lamaste 4.0.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/LICENSE.md +118 -0
- package/README.md +114 -0
- package/bin/create-lamaste.js +15 -0
- package/package.json +54 -0
- package/scripts/bundle-vendor.js +110 -0
- package/scripts/lamaste-cert-info +62 -0
- package/scripts/lamaste-certbot +96 -0
- package/scripts/lamaste-priv +693 -0
- package/src/index.js +646 -0
- package/src/lib/accounts.js +86 -0
- package/src/lib/binaries.js +137 -0
- package/src/lib/cert-help-page.js +160 -0
- package/src/lib/env.js +132 -0
- package/src/lib/http-redirect.js +38 -0
- package/src/lib/ownership.js +231 -0
- package/src/lib/relay-services.js +103 -0
- package/src/lib/secrets.js +19 -0
- package/src/lib/service-config.js +311 -0
- package/src/lib/summary.js +131 -0
- package/src/lib/wrappers.js +63 -0
- package/src/tasks/gatekeeper.js +233 -0
- package/src/tasks/harden.js +293 -0
- package/src/tasks/mtls.js +193 -0
- package/src/tasks/nginx.js +265 -0
- package/src/tasks/node.js +110 -0
- package/src/tasks/panel.js +408 -0
- package/src/tasks/redeploy.js +438 -0
- package/vendor/docs/00-introduction/how-it-works.md +447 -0
- package/vendor/docs/00-introduction/quickstart.md +472 -0
- package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
- package/vendor/docs/01-concepts/authentication.md +464 -0
- package/vendor/docs/01-concepts/authorization.md +242 -0
- package/vendor/docs/01-concepts/certificates.md +452 -0
- package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
- package/vendor/docs/01-concepts/mtls.md +490 -0
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
- package/vendor/docs/01-concepts/security-model.md +600 -0
- package/vendor/docs/01-concepts/tickets.md +414 -0
- package/vendor/docs/01-concepts/tunneling.md +367 -0
- package/vendor/docs/02-guides/agent-setup.md +194 -0
- package/vendor/docs/02-guides/certificate-management.md +341 -0
- package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
- package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
- package/vendor/docs/02-guides/disaster-recovery.md +464 -0
- package/vendor/docs/02-guides/first-tunnel.md +230 -0
- package/vendor/docs/02-guides/installation.md +393 -0
- package/vendor/docs/02-guides/managing-users.md +255 -0
- package/vendor/docs/02-guides/onboarding.md +244 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
- package/vendor/docs/02-guides/static-sites.md +364 -0
- package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
- package/vendor/docs/03-architecture/installer-flow.md +151 -0
- package/vendor/docs/03-architecture/installer.md +445 -0
- package/vendor/docs/03-architecture/management-flow.md +288 -0
- package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
- package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
- package/vendor/docs/03-architecture/overview.md +541 -0
- package/vendor/docs/03-architecture/panel-client.md +416 -0
- package/vendor/docs/03-architecture/panel-server.md +695 -0
- package/vendor/docs/03-architecture/state-management.md +529 -0
- package/vendor/docs/03-architecture/system-overview.md +167 -0
- package/vendor/docs/04-api-reference/certificates.md +1141 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
- package/vendor/docs/04-api-reference/onboarding.md +473 -0
- package/vendor/docs/04-api-reference/overview.md +404 -0
- package/vendor/docs/04-api-reference/services.md +267 -0
- package/vendor/docs/04-api-reference/settings.md +343 -0
- package/vendor/docs/04-api-reference/sites.md +716 -0
- package/vendor/docs/04-api-reference/system.md +268 -0
- package/vendor/docs/04-api-reference/tickets.md +618 -0
- package/vendor/docs/04-api-reference/tunnels.md +417 -0
- package/vendor/docs/04-api-reference/users.md +333 -0
- package/vendor/docs/05-operations/backup-and-restore.md +342 -0
- package/vendor/docs/05-operations/monitoring.md +285 -0
- package/vendor/docs/05-operations/uninstalling.md +344 -0
- package/vendor/docs/05-operations/upgrades.md +270 -0
- package/vendor/docs/06-reference/config-files.md +1080 -0
- package/vendor/docs/06-reference/glossary.md +528 -0
- package/vendor/docs/06-reference/installer-flags.md +260 -0
- package/vendor/docs/06-reference/ports-and-services.md +244 -0
- package/vendor/docs/06-reference/troubleshooting.md +665 -0
- package/vendor/docs/_index.json +233 -0
- package/vendor/docs/gatekeeper-design.md +953 -0
- package/vendor/docs/package.json +15 -0
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
- package/vendor/gatekeeper/dist/boot.d.ts +1 -0
- package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/boot.js +17 -0
- package/vendor/gatekeeper/dist/boot.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.js +45 -0
- package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
- package/vendor/gatekeeper/dist/index.d.ts +9 -0
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/index.js +13 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.js +42 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.js +79 -0
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.js +314 -0
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.js +247 -0
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.js +79 -0
- package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
- package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.js +151 -0
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.js +2 -0
- package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/index.js +251 -0
- package/vendor/gatekeeper/dist/server/index.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
- package/vendor/gatekeeper/package.json +49 -0
- package/vendor/server/bin/lamaste-server.js +204 -0
- package/vendor/server/package.json +31 -0
- package/vendor/server/src/commands/certs.js +224 -0
- package/vendor/server/src/commands/chisel.js +132 -0
- package/vendor/server/src/commands/logs.js +49 -0
- package/vendor/server/src/commands/plugins.js +321 -0
- package/vendor/server/src/commands/reset-admin.js +478 -0
- package/vendor/server/src/commands/restart.js +94 -0
- package/vendor/server/src/commands/sites.js +257 -0
- package/vendor/server/src/commands/status.js +158 -0
- package/vendor/server/src/commands/tunnels.js +365 -0
- package/vendor/server/src/commands/uninstall.js +326 -0
- package/vendor/server/src/config.js +38 -0
- package/vendor/server/src/exec.js +26 -0
- package/vendor/server/src/logger.js +39 -0
- package/vendor/server/src/ndjson.js +56 -0
- package/vendor/server/src/panel-api.js +85 -0
- package/vendor/server/src/state.js +45 -0
- package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
- package/vendor/server-ui/dist/index.html +13 -0
- package/vendor/serverd/package.json +44 -0
- package/vendor/serverd/src/boot.js +13 -0
- package/vendor/serverd/src/index.js +191 -0
- package/vendor/serverd/src/lib/app-error.js +14 -0
- package/vendor/serverd/src/lib/authelia.js +85 -0
- package/vendor/serverd/src/lib/certbot.js +74 -0
- package/vendor/serverd/src/lib/chisel-args.js +6 -0
- package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
- package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
- package/vendor/serverd/src/lib/chisel-users.js +109 -0
- package/vendor/serverd/src/lib/chisel.js +81 -0
- package/vendor/serverd/src/lib/config.js +190 -0
- package/vendor/serverd/src/lib/constants.js +8 -0
- package/vendor/serverd/src/lib/csr-signing.js +331 -0
- package/vendor/serverd/src/lib/enrollment.js +302 -0
- package/vendor/serverd/src/lib/files.js +19 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
- package/vendor/serverd/src/lib/invite-page.js +281 -0
- package/vendor/serverd/src/lib/ip-utils.js +79 -0
- package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
- package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
- package/vendor/serverd/src/lib/mtls.js +795 -0
- package/vendor/serverd/src/lib/nginx.js +1090 -0
- package/vendor/serverd/src/lib/pki-paths.js +41 -0
- package/vendor/serverd/src/lib/pki-sign.js +103 -0
- package/vendor/serverd/src/lib/plugins.js +538 -0
- package/vendor/serverd/src/lib/push-install.js +390 -0
- package/vendor/serverd/src/lib/revocation.js +112 -0
- package/vendor/serverd/src/lib/services.js +131 -0
- package/vendor/serverd/src/lib/session.js +151 -0
- package/vendor/serverd/src/lib/state-db.js +130 -0
- package/vendor/serverd/src/lib/state.js +140 -0
- package/vendor/serverd/src/lib/storage.js +63 -0
- package/vendor/serverd/src/lib/system-stats.js +58 -0
- package/vendor/serverd/src/lib/tickets.js +1556 -0
- package/vendor/serverd/src/lib/totp.js +76 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
- package/vendor/serverd/src/lib/user-access-session.js +142 -0
- package/vendor/serverd/src/lib/user-access.js +52 -0
- package/vendor/serverd/src/middleware/errors.js +63 -0
- package/vendor/serverd/src/middleware/mtls.js +120 -0
- package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
- package/vendor/serverd/src/middleware/role-guard.js +58 -0
- package/vendor/serverd/src/middleware/twofa-session.js +65 -0
- package/vendor/serverd/src/middleware/user-access-session.js +52 -0
- package/vendor/serverd/src/routes/enrollment.js +120 -0
- package/vendor/serverd/src/routes/health.js +22 -0
- package/vendor/serverd/src/routes/invite.js +163 -0
- package/vendor/serverd/src/routes/management/agents.js +182 -0
- package/vendor/serverd/src/routes/management/certs.js +911 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
- package/vendor/serverd/src/routes/management/identity.js +107 -0
- package/vendor/serverd/src/routes/management/invitations.js +166 -0
- package/vendor/serverd/src/routes/management/logs.js +218 -0
- package/vendor/serverd/src/routes/management/plugins.js +560 -0
- package/vendor/serverd/src/routes/management/services.js +63 -0
- package/vendor/serverd/src/routes/management/settings.js +325 -0
- package/vendor/serverd/src/routes/management/sites.js +522 -0
- package/vendor/serverd/src/routes/management/storage.js +192 -0
- package/vendor/serverd/src/routes/management/system.js +201 -0
- package/vendor/serverd/src/routes/management/tickets.js +457 -0
- package/vendor/serverd/src/routes/management/tunnels.js +709 -0
- package/vendor/serverd/src/routes/management/users.js +377 -0
- package/vendor/serverd/src/routes/management.js +36 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
- package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
- package/vendor/serverd/src/routes/onboarding/index.js +18 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
- package/vendor/serverd/src/routes/onboarding/status.js +12 -0
- package/vendor/serverd/src/routes/plugin-router.js +79 -0
- package/vendor/serverd/src/routes/user-access.js +389 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { base32Decode, generateTotpSecret } from './authelia.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a 6-digit TOTP code for a given secret and time step (RFC 6238 / RFC 4226).
|
|
6
|
+
*
|
|
7
|
+
* @param {string} secretBase32 - Base32-encoded secret
|
|
8
|
+
* @param {number} timeStep - The time step counter value
|
|
9
|
+
* @returns {string} 6-digit zero-padded code
|
|
10
|
+
*/
|
|
11
|
+
export function generateCode(secretBase32, timeStep) {
|
|
12
|
+
const key = base32Decode(secretBase32);
|
|
13
|
+
const timeBuffer = Buffer.alloc(8);
|
|
14
|
+
// Write 64-bit big-endian counter
|
|
15
|
+
timeBuffer.writeUInt32BE(Math.floor(timeStep / 0x100000000), 0);
|
|
16
|
+
timeBuffer.writeUInt32BE(timeStep >>> 0, 4);
|
|
17
|
+
|
|
18
|
+
const hmac = crypto.createHmac('sha1', key).update(timeBuffer).digest();
|
|
19
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
20
|
+
const code =
|
|
21
|
+
((hmac[offset] & 0x7f) << 24) |
|
|
22
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
23
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
24
|
+
(hmac[offset + 3] & 0xff);
|
|
25
|
+
|
|
26
|
+
return String(code % 1000000).padStart(6, '0');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Replay protection: track recently used codes per RFC 6238 Section 5.2.
|
|
30
|
+
// Key = "code:timeStep", purged after 90s (covers the full +/- 1 step window).
|
|
31
|
+
const usedCodes = new Map();
|
|
32
|
+
const USED_CODE_TTL_MS = 90_000;
|
|
33
|
+
|
|
34
|
+
function pruneUsedCodes() {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [key, ts] of usedCodes) {
|
|
37
|
+
if (now - ts > USED_CODE_TTL_MS) usedCodes.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify a TOTP code against the secret, allowing +/- 1 time step (30s each = 60s window).
|
|
43
|
+
* Each code can only be used once (replay protection per RFC 6238 Section 5.2).
|
|
44
|
+
*
|
|
45
|
+
* @param {string} secretBase32 - Base32-encoded secret
|
|
46
|
+
* @param {string} code - The 6-digit code to verify
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function verifyTotp(secretBase32, code) {
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
const period = 30;
|
|
52
|
+
const currentStep = Math.floor(now / period);
|
|
53
|
+
|
|
54
|
+
for (let i = -1; i <= 1; i++) {
|
|
55
|
+
const step = currentStep + i;
|
|
56
|
+
if (generateCode(secretBase32, step) === code) {
|
|
57
|
+
const replayKey = `${code}:${step}`;
|
|
58
|
+
if (usedCodes.has(replayKey)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
usedCodes.set(replayKey, Date.now());
|
|
62
|
+
pruneUsedCodes();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a TOTP secret and otpauth URI for the admin panel.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{ secret: string, uri: string }}
|
|
73
|
+
*/
|
|
74
|
+
export function generateAdminTotpSecret() {
|
|
75
|
+
return generateTotpSecret('admin', { issuer: 'Lamaste Panel' });
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dependency objects the core tunnel workflows take
|
|
3
|
+
* (`@lamalibre/lamaste/server` createTunnel, updateTunnel, ...), wired to the
|
|
4
|
+
* daemon's nginx, certbot, chisel and state modules. Shared by the tunnel
|
|
5
|
+
* routes and by agent revocation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
writePublicVhost,
|
|
10
|
+
writeAuthenticatedVhost,
|
|
11
|
+
writeRestrictedVhost,
|
|
12
|
+
removeAppVhost,
|
|
13
|
+
enableAppVhost,
|
|
14
|
+
disableAppVhost,
|
|
15
|
+
writeAgentPanelVhost,
|
|
16
|
+
removeAgentPanelVhost,
|
|
17
|
+
enableAgentPanelVhost,
|
|
18
|
+
disableAgentPanelVhost,
|
|
19
|
+
} from './nginx.js';
|
|
20
|
+
import { issueTunnelCert } from './certbot.js';
|
|
21
|
+
import { syncChisel } from './chisel.js';
|
|
22
|
+
import { readTunnels, writeTunnels } from './state.js';
|
|
23
|
+
|
|
24
|
+
export const tunnelNginxDeps = Object.freeze({
|
|
25
|
+
writePublicVhost,
|
|
26
|
+
writeAuthenticatedVhost,
|
|
27
|
+
writeRestrictedVhost,
|
|
28
|
+
writeAgentPanelVhost,
|
|
29
|
+
removeAppVhost,
|
|
30
|
+
removeAgentPanelVhost,
|
|
31
|
+
enableAppVhost,
|
|
32
|
+
disableAppVhost,
|
|
33
|
+
enableAgentPanelVhost,
|
|
34
|
+
disableAgentPanelVhost,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const tunnelCertbotDeps = Object.freeze({ issueTunnelCert });
|
|
38
|
+
|
|
39
|
+
export const tunnelChiselDeps = Object.freeze({ syncChisel });
|
|
40
|
+
|
|
41
|
+
export const tunnelStateDeps = Object.freeze({ readTunnels, writeTunnels });
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const SESSION_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
|
|
4
|
+
const INACTIVITY_TIMEOUT = 2 * 60 * 60; // 2 hours in seconds
|
|
5
|
+
const REFRESH_THRESHOLD = 60; // Only re-sign if lastActivity is older than 60s
|
|
6
|
+
|
|
7
|
+
export { SESSION_MAX_AGE, INACTIVITY_TIMEOUT, REFRESH_THRESHOLD };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HMAC-sign a base64url payload string.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} payloadB64 - Base64url-encoded payload
|
|
13
|
+
* @param {string} secretHex - Hex-encoded secret
|
|
14
|
+
* @returns {string} Base64url-encoded HMAC signature
|
|
15
|
+
*/
|
|
16
|
+
function signPayload(payloadB64, secretHex) {
|
|
17
|
+
return crypto
|
|
18
|
+
.createHmac('sha256', Buffer.from(secretHex, 'hex'))
|
|
19
|
+
.update(payloadB64)
|
|
20
|
+
.digest('base64url');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a signed user-access session token.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} sessionSecret - Hex-encoded session secret
|
|
27
|
+
* @param {string} username - Authelia username to bind the session to
|
|
28
|
+
* @returns {{ value: string, maxAge: number, expiresAt: string }}
|
|
29
|
+
*/
|
|
30
|
+
export function createUserSession(sessionSecret, username) {
|
|
31
|
+
const now = Math.floor(Date.now() / 1000);
|
|
32
|
+
const payload = {
|
|
33
|
+
iat: now,
|
|
34
|
+
exp: now + SESSION_MAX_AGE,
|
|
35
|
+
lastActivity: now,
|
|
36
|
+
username,
|
|
37
|
+
type: 'user-access',
|
|
38
|
+
};
|
|
39
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
40
|
+
const sig = signPayload(payloadB64, sessionSecret);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
value: `${payloadB64}.${sig}`,
|
|
44
|
+
maxAge: SESSION_MAX_AGE,
|
|
45
|
+
expiresAt: new Date((now + SESSION_MAX_AGE) * 1000).toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a user-access session token.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} tokenValue - The token (payload.sig)
|
|
53
|
+
* @param {string} sessionSecret - Hex-encoded session secret
|
|
54
|
+
* @param {{ userEpochs?: Record<string, number> }} [options]
|
|
55
|
+
* userEpochs maps username → Unix-second epoch. If the session's `iat`
|
|
56
|
+
* is older than the user's epoch, the session is rejected as revoked.
|
|
57
|
+
* This is how Authelia user deletions invalidate live sessions whose
|
|
58
|
+
* HMAC signature is otherwise still valid.
|
|
59
|
+
* @returns {{ valid: boolean, payload?: object, reason?: string }}
|
|
60
|
+
*/
|
|
61
|
+
export function validateUserSession(tokenValue, sessionSecret, options = {}) {
|
|
62
|
+
if (!tokenValue || typeof tokenValue !== 'string') {
|
|
63
|
+
return { valid: false, reason: 'missing' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const dotIndex = tokenValue.indexOf('.');
|
|
67
|
+
if (dotIndex === -1) {
|
|
68
|
+
return { valid: false, reason: 'malformed' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const payloadB64 = tokenValue.slice(0, dotIndex);
|
|
72
|
+
const sig = tokenValue.slice(dotIndex + 1);
|
|
73
|
+
|
|
74
|
+
const expectedSig = signPayload(payloadB64, sessionSecret);
|
|
75
|
+
|
|
76
|
+
// Timing-safe comparison
|
|
77
|
+
const sigBuf = Buffer.from(sig);
|
|
78
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
79
|
+
if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) {
|
|
80
|
+
return { valid: false, reason: 'invalid_signature' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
86
|
+
} catch {
|
|
87
|
+
return { valid: false, reason: 'parse_error' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Must be a user-access session (not a 2FA session)
|
|
91
|
+
if (payload.type !== 'user-access') {
|
|
92
|
+
return { valid: false, reason: 'wrong_type' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!payload.username) {
|
|
96
|
+
return { valid: false, reason: 'missing_username' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
|
|
101
|
+
if (payload.exp && now > payload.exp) {
|
|
102
|
+
return { valid: false, reason: 'expired' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (payload.lastActivity && now - payload.lastActivity > INACTIVITY_TIMEOUT) {
|
|
106
|
+
return { valid: false, reason: 'inactive' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Per-user invalidation epoch (set when a user is deleted from Authelia).
|
|
110
|
+
// Reject sessions issued before that epoch — they belong to a user who no
|
|
111
|
+
// longer exists, even if a same-named user is later re-created.
|
|
112
|
+
const userEpochs = options.userEpochs;
|
|
113
|
+
if (userEpochs && typeof userEpochs === 'object') {
|
|
114
|
+
const epoch = userEpochs[payload.username];
|
|
115
|
+
if (typeof epoch === 'number' && typeof payload.iat === 'number' && payload.iat < epoch) {
|
|
116
|
+
return { valid: false, reason: 'revoked' };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { valid: true, payload };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Refresh a user-access session by updating lastActivity and re-signing.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} payload - The existing session payload
|
|
127
|
+
* @param {string} sessionSecret - Hex-encoded session secret
|
|
128
|
+
* @returns {{ value: string, maxAge: number }}
|
|
129
|
+
*/
|
|
130
|
+
export function refreshUserSession(payload, sessionSecret) {
|
|
131
|
+
const now = Math.floor(Date.now() / 1000);
|
|
132
|
+
const updated = { ...payload, lastActivity: now };
|
|
133
|
+
const payloadB64 = Buffer.from(JSON.stringify(updated)).toString('base64url');
|
|
134
|
+
const sig = signPayload(payloadB64, sessionSecret);
|
|
135
|
+
|
|
136
|
+
const remaining = Math.max(0, updated.exp - now);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
value: `${payloadB64}.${sig}`,
|
|
140
|
+
maxAge: remaining,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim — user-access grant + OTP management now lives in
|
|
3
|
+
* `@lamalibre/lamaste/server`. This file resolves the dataDir from env
|
|
4
|
+
* and forwards to the core library, preserving the daemon-local API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createGrant as createGrantCore,
|
|
9
|
+
listGrants as listGrantsCore,
|
|
10
|
+
listGrantsForUser as listGrantsForUserCore,
|
|
11
|
+
revokeGrant as revokeGrantCore,
|
|
12
|
+
removeGrantsForUser as removeGrantsForUserCore,
|
|
13
|
+
consumeGrant as consumeGrantCore,
|
|
14
|
+
createOTP as createOTPCore,
|
|
15
|
+
validateAndConsumeOTP as validateAndConsumeOTPCore,
|
|
16
|
+
} from '@lamalibre/lamaste/server';
|
|
17
|
+
|
|
18
|
+
function dataDir() {
|
|
19
|
+
return process.env.LAMALIBRE_LAMASTE_DATA_DIR || '/etc/lamalibre/lamaste';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createGrant(username, pluginName, logger, options = {}) {
|
|
23
|
+
return createGrantCore(dataDir(), username, pluginName, logger, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function listGrants() {
|
|
27
|
+
return listGrantsCore(dataDir());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listGrantsForUser(username) {
|
|
31
|
+
return listGrantsForUserCore(dataDir(), username);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function revokeGrant(grantId, logger) {
|
|
35
|
+
return revokeGrantCore(dataDir(), grantId, logger);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function removeGrantsForUser(username, logger) {
|
|
39
|
+
return removeGrantsForUserCore(dataDir(), username, logger);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function consumeGrant(grantId, username, logger) {
|
|
43
|
+
return consumeGrantCore(dataDir(), grantId, username, logger);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createOTP(username, input, logger) {
|
|
47
|
+
return createOTPCore(dataDir(), username, input, logger);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateAndConsumeOTP(token, verifier) {
|
|
51
|
+
return validateAndConsumeOTPCore(dataDir(), token, verifier);
|
|
52
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global error handler plugin for Fastify.
|
|
5
|
+
*
|
|
6
|
+
* Every error response follows this contract:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* error: string; // Always present. Human-readable error summary.
|
|
10
|
+
* details?: object; // Optional. Additional structured information.
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Wrapped with fastify-plugin to break encapsulation — the handler applies
|
|
14
|
+
* to all routes, not just those registered in the same plugin context.
|
|
15
|
+
*/
|
|
16
|
+
async function errorHandlerPlugin(fastify, _opts) {
|
|
17
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
18
|
+
request.log.error(error);
|
|
19
|
+
|
|
20
|
+
const isDev = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
|
21
|
+
|
|
22
|
+
// Zod validation errors
|
|
23
|
+
if (error.name === 'ZodError' || Array.isArray(error.issues)) {
|
|
24
|
+
const issues = error.issues.map((issue) => ({
|
|
25
|
+
path: issue.path,
|
|
26
|
+
message: issue.message,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
return reply.code(400).send({
|
|
30
|
+
error: 'Validation failed',
|
|
31
|
+
details: { issues },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Operational errors (AppError)
|
|
36
|
+
if (error.isOperational === true) {
|
|
37
|
+
const body = { error: error.message };
|
|
38
|
+
if (error.details !== null && error.details !== undefined) {
|
|
39
|
+
body.details = error.details;
|
|
40
|
+
}
|
|
41
|
+
return reply.code(error.statusCode).send(body);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fastify built-in errors (malformed JSON, missing content-type, etc.)
|
|
45
|
+
if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
|
46
|
+
return reply.code(error.statusCode).send({
|
|
47
|
+
error: error.message,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Unexpected errors — never leak internals in production
|
|
52
|
+
const body = { error: 'Internal server error' };
|
|
53
|
+
if (isDev) {
|
|
54
|
+
body.details = {
|
|
55
|
+
message: error.message,
|
|
56
|
+
stack: error.stack,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return reply.code(500).send(body);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default fp(errorHandlerPlugin);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
import { isRevoked } from '../lib/revocation.js';
|
|
3
|
+
import { getAgentCapabilities, getAgentAllowedSites, PLUGIN_AGENT_CN_PREFIX } from '../lib/mtls.js';
|
|
4
|
+
|
|
5
|
+
let devWarningLogged = false;
|
|
6
|
+
|
|
7
|
+
async function mtlsPlugin(fastify, _opts) {
|
|
8
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
9
|
+
// Health check endpoint is always accessible without mTLS
|
|
10
|
+
// (used by systemd, load balancers, and internal provisioning checks)
|
|
11
|
+
if (request.url === '/api/health') {
|
|
12
|
+
request.certRole = 'admin';
|
|
13
|
+
request.certLabel = null;
|
|
14
|
+
request.certCapabilities = null;
|
|
15
|
+
request.certAllowedSites = null;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
20
|
+
|
|
21
|
+
if (isDev) {
|
|
22
|
+
if (!devWarningLogged) {
|
|
23
|
+
request.log.warn('mTLS verification bypassed — running in development mode');
|
|
24
|
+
devWarningLogged = true;
|
|
25
|
+
}
|
|
26
|
+
request.certRole = 'admin';
|
|
27
|
+
request.certLabel = null;
|
|
28
|
+
request.certCapabilities = null;
|
|
29
|
+
request.certAllowedSites = null;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clientVerify = request.headers['x-ssl-client-verify'];
|
|
34
|
+
|
|
35
|
+
if (clientVerify !== 'SUCCESS') {
|
|
36
|
+
return reply.code(403).send({
|
|
37
|
+
error: 'mTLS certificate required',
|
|
38
|
+
details: {
|
|
39
|
+
hint: 'Access to the Lamaste panel requires a valid client certificate.',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check certificate serial against revocation list
|
|
45
|
+
const serial = request.headers['x-ssl-client-serial'];
|
|
46
|
+
if (serial && (await isRevoked(serial))) {
|
|
47
|
+
return reply.code(403).send({
|
|
48
|
+
error: 'Certificate has been revoked',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Parse the certificate DN to extract role
|
|
53
|
+
const dn = request.headers['x-ssl-client-dn'] || '';
|
|
54
|
+
const cnMatch = dn.match(/CN=([^,]+)/);
|
|
55
|
+
const cn = cnMatch ? cnMatch[1] : '';
|
|
56
|
+
|
|
57
|
+
if (cn.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
|
|
58
|
+
// Plugin-agent: CN = plugin-agent:<delegatingLabel>:<pluginAgentLabel>
|
|
59
|
+
// certLabel includes the full CN so it matches the registry label
|
|
60
|
+
request.certRole = 'plugin-agent';
|
|
61
|
+
request.certLabel = cn;
|
|
62
|
+
// Extract the delegating agent label
|
|
63
|
+
const afterPrefix = cn.slice(PLUGIN_AGENT_CN_PREFIX.length);
|
|
64
|
+
const colonIndex = afterPrefix.indexOf(':');
|
|
65
|
+
request.certDelegatedBy = colonIndex !== -1 ? afterPrefix.slice(0, colonIndex) : afterPrefix;
|
|
66
|
+
try {
|
|
67
|
+
request.certCapabilities = await getAgentCapabilities(cn);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
request.log.warn(
|
|
70
|
+
{ err, label: cn },
|
|
71
|
+
'Failed to load plugin-agent capabilities, using defaults',
|
|
72
|
+
);
|
|
73
|
+
request.certCapabilities = [];
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
request.certAllowedSites = await getAgentAllowedSites(cn);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
request.log.warn(
|
|
79
|
+
{ err, label: cn },
|
|
80
|
+
'Failed to load plugin-agent allowed sites, using defaults',
|
|
81
|
+
);
|
|
82
|
+
request.certAllowedSites = [];
|
|
83
|
+
}
|
|
84
|
+
} else if (cn.startsWith('agent:')) {
|
|
85
|
+
request.certRole = 'agent';
|
|
86
|
+
request.certLabel = cn.slice('agent:'.length);
|
|
87
|
+
try {
|
|
88
|
+
request.certCapabilities = await getAgentCapabilities(request.certLabel);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
request.log.warn(
|
|
91
|
+
{ err, label: request.certLabel },
|
|
92
|
+
'Failed to load agent capabilities, using defaults',
|
|
93
|
+
);
|
|
94
|
+
request.certCapabilities = ['tunnels:read'];
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
request.certAllowedSites = await getAgentAllowedSites(request.certLabel);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
request.log.warn(
|
|
100
|
+
{ err, label: request.certLabel },
|
|
101
|
+
'Failed to load agent allowed sites, using defaults',
|
|
102
|
+
);
|
|
103
|
+
request.certAllowedSites = [];
|
|
104
|
+
}
|
|
105
|
+
} else if (cn === 'admin') {
|
|
106
|
+
request.certRole = 'admin';
|
|
107
|
+
request.certLabel = null;
|
|
108
|
+
request.certCapabilities = null;
|
|
109
|
+
request.certAllowedSites = null;
|
|
110
|
+
} else {
|
|
111
|
+
// Defense-in-depth: nginx is the primary gate, but never grant any role
|
|
112
|
+
// for an unrecognized CN (formerly defaulted to admin — privilege-escalation
|
|
113
|
+
// hazard if nginx config drifts).
|
|
114
|
+
request.log.warn({ cn, dn }, 'Rejecting unrecognized CN');
|
|
115
|
+
return reply.code(403).send({ error: 'Unrecognized client certificate' });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default fp(mtlsPlugin);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getConfig } from '../lib/config.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fastify onRequest hook that blocks access when onboarding is already completed.
|
|
5
|
+
* Used to protect onboarding routes.
|
|
6
|
+
*/
|
|
7
|
+
export function onboardingOnly() {
|
|
8
|
+
return async function onboardingOnlyHook(_request, reply) {
|
|
9
|
+
const config = getConfig();
|
|
10
|
+
if (config.onboarding.status === 'COMPLETED') {
|
|
11
|
+
return reply.code(410).send({ error: 'Onboarding already completed' });
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fastify onRequest hook that blocks access when onboarding is not yet completed.
|
|
18
|
+
* Used to protect management routes.
|
|
19
|
+
*/
|
|
20
|
+
export function managementOnly() {
|
|
21
|
+
return async function managementOnlyHook(_request, reply) {
|
|
22
|
+
const config = getConfig();
|
|
23
|
+
if (config.onboarding.status !== 'COMPLETED') {
|
|
24
|
+
return reply.code(503).send({
|
|
25
|
+
error: 'Onboarding not complete',
|
|
26
|
+
onboardingStatus: config.onboarding.status,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Role guard plugin — decorates fastify with `requireRole(allowedRoles, opts)`.
|
|
5
|
+
*
|
|
6
|
+
* Returns a preHandler function that checks `request.certRole` against
|
|
7
|
+
* the provided allowedRoles array. For agent roles, optionally checks
|
|
8
|
+
* `request.certCapabilities` for a required capability.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* fastify.requireRole(['admin']) — admin only
|
|
12
|
+
* fastify.requireRole(['admin', 'agent']) — admin + any agent
|
|
13
|
+
* fastify.requireRole(['admin', 'agent'], { capability: 'tunnels:write' }) — admin + agents with capability
|
|
14
|
+
*/
|
|
15
|
+
function roleGuard(fastify, _opts, done) {
|
|
16
|
+
fastify.decorate('requireRole', function (allowedRoles, opts) {
|
|
17
|
+
const { capability } = opts || {};
|
|
18
|
+
|
|
19
|
+
return async function (request, reply) {
|
|
20
|
+
const role = request.certRole || 'unknown';
|
|
21
|
+
|
|
22
|
+
// Admin always passes — no capability check needed
|
|
23
|
+
if (role === 'admin') return;
|
|
24
|
+
|
|
25
|
+
// plugin-agent is a subset of agent — accept it wherever agent is accepted
|
|
26
|
+
const effectiveRole =
|
|
27
|
+
role === 'plugin-agent' && allowedRoles.includes('agent') ? 'agent' : role;
|
|
28
|
+
|
|
29
|
+
// Check if the role is in the allowed list
|
|
30
|
+
if (!allowedRoles.includes(effectiveRole)) {
|
|
31
|
+
return reply.code(403).send({
|
|
32
|
+
error: 'Insufficient certificate scope',
|
|
33
|
+
details: {
|
|
34
|
+
required: allowedRoles,
|
|
35
|
+
current: role,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If a capability is required, check the agent (or plugin-agent) has it
|
|
41
|
+
if (capability && (effectiveRole === 'agent' || role === 'plugin-agent')) {
|
|
42
|
+
const caps = request.certCapabilities || [];
|
|
43
|
+
if (!caps.includes(capability)) {
|
|
44
|
+
return reply.code(403).send({
|
|
45
|
+
error: 'Insufficient certificate capability',
|
|
46
|
+
details: {
|
|
47
|
+
required: capability,
|
|
48
|
+
granted: caps,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
done();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default fp(roleGuard, { name: 'role-guard' });
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
import { getConfig } from '../lib/config.js';
|
|
3
|
+
import {
|
|
4
|
+
COOKIE_NAME,
|
|
5
|
+
COOKIE_OPTIONS,
|
|
6
|
+
REFRESH_THRESHOLD,
|
|
7
|
+
validateSession,
|
|
8
|
+
refreshSession,
|
|
9
|
+
} from '../lib/session.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Exempt paths that must remain accessible without a 2FA session.
|
|
13
|
+
* These include the health check, the 2FA status endpoint, and the verify endpoint.
|
|
14
|
+
*/
|
|
15
|
+
const EXEMPT_PATHS = new Set(['/api/health', '/api/settings/2fa', '/api/settings/2fa/verify']);
|
|
16
|
+
|
|
17
|
+
async function twofaSessionPlugin(fastify, _opts) {
|
|
18
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
19
|
+
const config = getConfig();
|
|
20
|
+
|
|
21
|
+
// 2FA not enabled — skip entirely
|
|
22
|
+
if (!config.panel2fa || !config.panel2fa.enabled) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Agents bypass 2FA — both regular agent certs and plugin-agent certs
|
|
27
|
+
// (plugin-agents are automated callers; only humans need the second factor)
|
|
28
|
+
if (request.certRole === 'agent' || request.certRole === 'plugin-agent') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Exempt paths
|
|
33
|
+
if (EXEMPT_PATHS.has(request.url) || EXEMPT_PATHS.has(request.url.split('?')[0])) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Admin must have a valid 2FA session
|
|
38
|
+
if (request.certRole === 'admin') {
|
|
39
|
+
const sessionSecret = config.sessionSecret;
|
|
40
|
+
if (!sessionSecret) {
|
|
41
|
+
return reply.code(401).send({ error: '2fa_required' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cookieValue = request.cookies?.[COOKIE_NAME];
|
|
45
|
+
const certSerial = request.headers['x-ssl-client-serial'] || undefined;
|
|
46
|
+
const result = validateSession(cookieValue, sessionSecret, certSerial);
|
|
47
|
+
|
|
48
|
+
if (!result.valid) {
|
|
49
|
+
return reply.code(401).send({ error: '2fa_required' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Throttle refresh: only re-sign if lastActivity is stale (>60s)
|
|
53
|
+
const now = Math.floor(Date.now() / 1000);
|
|
54
|
+
if (now - result.payload.lastActivity >= REFRESH_THRESHOLD) {
|
|
55
|
+
const refreshed = refreshSession(result.payload, sessionSecret);
|
|
56
|
+
reply.setCookie(COOKIE_NAME, refreshed.value, {
|
|
57
|
+
...COOKIE_OPTIONS,
|
|
58
|
+
maxAge: refreshed.maxAge,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default fp(twofaSessionPlugin, { name: 'twofa-session' });
|