@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,331 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { access, constants, mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { randomCertSerial, readCsrCN, signAgentCsr } from './pki-sign.js';
|
|
5
|
+
import {
|
|
6
|
+
readCertExpiry,
|
|
7
|
+
loadAgentRegistry,
|
|
8
|
+
saveAgentRegistry,
|
|
9
|
+
withRegistryLock,
|
|
10
|
+
getValidCapabilities,
|
|
11
|
+
} from './mtls.js';
|
|
12
|
+
import { addChiselCredential } from './chisel-users.js';
|
|
13
|
+
|
|
14
|
+
const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
|
|
15
|
+
const AGENTS_DIR = `${PKI_DIR}/agents`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sign an admin CSR with the panel CA for hardware-bound admin upgrade.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY (B9): the panel does not issue certificates with `CN=admin`;
|
|
21
|
+
* `signAgentCsr` refuses any CN that is not an agent label.
|
|
22
|
+
*
|
|
23
|
+
* Admin cert issuance now lives only in `lamaste-server reset-admin`, which
|
|
24
|
+
* runs as root directly (not via sudo) and has its own audit/recovery story.
|
|
25
|
+
* Hardware-bound admin upgrades must be performed via that root-only flow.
|
|
26
|
+
*
|
|
27
|
+
* This stub is preserved so the panel route returns a clear, actionable error
|
|
28
|
+
* to the operator instead of failing with an opaque sudo error. Callers
|
|
29
|
+
* receive a 503 with instructions.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} _csrPem - PEM-encoded CSR (ignored)
|
|
32
|
+
* @param {import('pino').Logger} logger
|
|
33
|
+
* @returns {Promise<never>}
|
|
34
|
+
*/
|
|
35
|
+
export async function signAdminCSR(_csrPem, logger) {
|
|
36
|
+
logger.warn(
|
|
37
|
+
'Refused admin CSR signing request — panel-side admin cert issuance is disabled (B9). Use `sudo lamaste-server reset-admin` on the server.',
|
|
38
|
+
);
|
|
39
|
+
throw Object.assign(
|
|
40
|
+
new Error(
|
|
41
|
+
'Panel-initiated admin cert issuance is disabled for security. ' +
|
|
42
|
+
'Run `sudo lamaste-server reset-admin` on the server console to issue a new admin certificate.',
|
|
43
|
+
),
|
|
44
|
+
{ statusCode: 503 },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rotate an agent's certificate via CSR for hardware-bound upgrade.
|
|
50
|
+
*
|
|
51
|
+
* Signs a new CSR for an existing agent, revokes the old certificate,
|
|
52
|
+
* preserves capabilities and allowed sites, and sets enrollmentMethod
|
|
53
|
+
* to 'hardware-bound'.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} csrPem - PEM-encoded CSR
|
|
56
|
+
* @param {string} label - Agent label (must match an existing non-revoked agent)
|
|
57
|
+
* @param {import('pino').Logger} logger
|
|
58
|
+
* @returns {Promise<{ certPem: string, caCertPem: string, serial: string, expiresAt: string, label: string }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function rotateAgentCSR(csrPem, label, logger) {
|
|
61
|
+
// Defense-in-depth: re-validate label for DN safety
|
|
62
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(label) || label.length > 50) {
|
|
63
|
+
throw Object.assign(new Error('Invalid agent label'), { statusCode: 400 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (csrPem.length > 8192) {
|
|
67
|
+
throw Object.assign(new Error('CSR too large'), { statusCode: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return withRegistryLock(async () => {
|
|
71
|
+
// Find the existing non-revoked agent entry
|
|
72
|
+
const registry = await loadAgentRegistry();
|
|
73
|
+
const existing = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
74
|
+
if (!existing) {
|
|
75
|
+
throw Object.assign(new Error(`Agent certificate "${label}" not found`), {
|
|
76
|
+
statusCode: 404,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Verify CA key exists
|
|
81
|
+
try {
|
|
82
|
+
await access(`${PKI_DIR}/ca.key`, constants.R_OK);
|
|
83
|
+
} catch {
|
|
84
|
+
throw Object.assign(new Error('CA key not found — cannot sign certificate'), {
|
|
85
|
+
statusCode: 500,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tmpSuffix = crypto.randomBytes(8).toString('hex');
|
|
90
|
+
const csrPath = `${AGENTS_DIR}/.rotate-csr-${tmpSuffix}.pem`;
|
|
91
|
+
const certPath = `${AGENTS_DIR}/.rotate-cert-${tmpSuffix}.pem`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Write the CSR to a temp file
|
|
95
|
+
await writeFile(csrPath, csrPem, { mode: 0o600 });
|
|
96
|
+
|
|
97
|
+
// Validate CSR structure and signature
|
|
98
|
+
try {
|
|
99
|
+
await execa('openssl', ['req', '-verify', '-in', csrPath, '-noout']);
|
|
100
|
+
} catch {
|
|
101
|
+
throw Object.assign(new Error('Invalid CSR: structure or signature verification failed'), {
|
|
102
|
+
statusCode: 400,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The certificate keeps the CSR's subject, and signAgentCsr checks
|
|
107
|
+
// only its shape — so verify here that the CSR's CN is exactly
|
|
108
|
+
// `agent:<label>`. A mismatch indicates either a buggy client or an
|
|
109
|
+
// attempt to rotate one agent's cert into a different label.
|
|
110
|
+
const expectedCN = `agent:${label}`;
|
|
111
|
+
const actualCN = await readCsrCN(csrPath);
|
|
112
|
+
if (actualCN !== expectedCN) {
|
|
113
|
+
throw Object.assign(new Error(`CSR CN must be ${expectedCN}, got ${actualCN ?? 'none'}`), {
|
|
114
|
+
statusCode: 400,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const serial = randomCertSerial();
|
|
119
|
+
logger.info({ label, serial }, 'Signing rotation CSR');
|
|
120
|
+
await signAgentCsr(csrPath, certPath, serial, 730);
|
|
121
|
+
|
|
122
|
+
// Read expiry
|
|
123
|
+
const expiry = await readCertExpiry(certPath);
|
|
124
|
+
const expiresAt = expiry?.expiresAt || new Date(Date.now() + 730 * 86400000).toISOString();
|
|
125
|
+
|
|
126
|
+
// Read the signed certificate PEM
|
|
127
|
+
const certPem = await readFile(certPath, 'utf-8');
|
|
128
|
+
|
|
129
|
+
// Read the CA certificate PEM
|
|
130
|
+
const caCertPem = await readFile(`${PKI_DIR}/ca.crt`, 'utf-8');
|
|
131
|
+
|
|
132
|
+
// Revoke the old certificate
|
|
133
|
+
const { addToRevocationList } = await import('./revocation.js');
|
|
134
|
+
logger.info(
|
|
135
|
+
{ label, oldSerial: existing.serial },
|
|
136
|
+
'Revoking old agent certificate for hardware-bound upgrade',
|
|
137
|
+
);
|
|
138
|
+
await addToRevocationList(existing.serial, `agent:${label} (upgraded to hardware-bound)`);
|
|
139
|
+
|
|
140
|
+
// Update the existing registry entry atomically: new serial, new expiry,
|
|
141
|
+
// mark as hardware-bound, preserve allowedSites and the *currently
|
|
142
|
+
// valid* subset of capabilities. Filtering against the live
|
|
143
|
+
// `getValidCapabilities()` set ensures that capabilities contributed
|
|
144
|
+
// by a now-uninstalled plugin or a now-deregistered ticket scope are
|
|
145
|
+
// dropped from the rotated cert, rather than being silently re-issued.
|
|
146
|
+
const storedCaps = Array.isArray(existing.capabilities) ? existing.capabilities : [];
|
|
147
|
+
const validCaps = new Set(getValidCapabilities());
|
|
148
|
+
const liveCaps = storedCaps.filter((c) => validCaps.has(c));
|
|
149
|
+
const droppedCapabilities = storedCaps.filter((c) => !validCaps.has(c));
|
|
150
|
+
if (droppedCapabilities.length > 0) {
|
|
151
|
+
logger.info(
|
|
152
|
+
{ label, droppedCapabilities, reason: 'no_longer_valid' },
|
|
153
|
+
'Dropping stale agent capabilities during hardware-bound rotation',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
existing.capabilities = liveCaps;
|
|
157
|
+
existing.serial = serial;
|
|
158
|
+
existing.expiresAt = expiresAt;
|
|
159
|
+
existing.enrollmentMethod = 'hardware-bound';
|
|
160
|
+
existing.revoked = false;
|
|
161
|
+
await saveAgentRegistry(registry);
|
|
162
|
+
|
|
163
|
+
logger.info({ label, serial }, 'Agent certificate rotated for hardware-bound upgrade');
|
|
164
|
+
|
|
165
|
+
return { certPem, caCertPem, serial, expiresAt, label };
|
|
166
|
+
} finally {
|
|
167
|
+
await unlink(csrPath).catch(() => {});
|
|
168
|
+
await unlink(certPath).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sign an externally-generated CSR with the panel CA.
|
|
175
|
+
*
|
|
176
|
+
* Validates that the CSR subject matches the expected agent CN format,
|
|
177
|
+
* signs it with the CA (2-year validity), reads serial and expiry,
|
|
178
|
+
* and adds the agent to the registry with `enrollmentMethod: 'hardware-bound'`.
|
|
179
|
+
*
|
|
180
|
+
* For delegated enrollments, uses the `plugin-agent:<delegatingLabel>:<pluginAgentLabel>`
|
|
181
|
+
* CN format and stores the registry entry with `enrollmentType: 'delegated'` and
|
|
182
|
+
* `delegatedBy` field.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} csrPem - PEM-encoded CSR
|
|
185
|
+
* @param {string} label - Agent label (must match CSR subject)
|
|
186
|
+
* @param {string[]} capabilities - Capability list
|
|
187
|
+
* @param {string[]} allowedSites - Allowed site labels
|
|
188
|
+
* @param {import('pino').Logger} logger
|
|
189
|
+
* @param {{ type?: 'delegated', delegatedBy?: string }} [opts] - Optional enrollment metadata
|
|
190
|
+
* @returns {Promise<{ certPem: string, caCertPem: string, serial: string, expiresAt: string, label: string }>}
|
|
191
|
+
*/
|
|
192
|
+
export async function signCSR(csrPem, label, capabilities, allowedSites, logger, opts) {
|
|
193
|
+
const isDelegated = opts?.type === 'delegated';
|
|
194
|
+
|
|
195
|
+
// Defense-in-depth: re-validate label for DN safety even though routes validate via Zod.
|
|
196
|
+
// For delegated enrollments, the label is "plugin-agent:<delegating>:<plugin>" — validate each segment.
|
|
197
|
+
if (isDelegated) {
|
|
198
|
+
const match = label.match(/^plugin-agent:([a-z0-9][a-z0-9-]*):([a-z0-9][a-z0-9-]*)$/);
|
|
199
|
+
if (!match || label.length > 150) {
|
|
200
|
+
throw Object.assign(new Error('Invalid plugin-agent label'), { statusCode: 400 });
|
|
201
|
+
}
|
|
202
|
+
} else if (!/^[a-z0-9][a-z0-9-]*$/.test(label) || label.length > 50) {
|
|
203
|
+
throw Object.assign(new Error('Invalid agent label'), { statusCode: 400 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Reject oversized CSRs (a 4096-bit RSA CSR is ~1600 bytes PEM)
|
|
207
|
+
if (csrPem.length > 8192) {
|
|
208
|
+
throw Object.assign(new Error('CSR too large'), { statusCode: 400 });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return withRegistryLock(async () => {
|
|
212
|
+
// Check registry for duplicate (non-revoked) label
|
|
213
|
+
const registry = await loadAgentRegistry();
|
|
214
|
+
const existing = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
215
|
+
if (existing) {
|
|
216
|
+
throw Object.assign(new Error(`Agent certificate with label "${label}" already exists`), {
|
|
217
|
+
statusCode: 409,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Verify CA key exists
|
|
222
|
+
try {
|
|
223
|
+
await access(`${PKI_DIR}/ca.key`, constants.R_OK);
|
|
224
|
+
} catch {
|
|
225
|
+
throw Object.assign(new Error('CA key not found — cannot sign certificate'), {
|
|
226
|
+
statusCode: 500,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tmpSuffix = crypto.randomBytes(8).toString('hex');
|
|
231
|
+
const csrPath = `${AGENTS_DIR}/.enroll-csr-${tmpSuffix}.pem`;
|
|
232
|
+
const certPath = `${AGENTS_DIR}/.enroll-cert-${tmpSuffix}.pem`;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Ensure agents directory exists
|
|
236
|
+
await mkdir(AGENTS_DIR, { recursive: true, mode: 0o700 });
|
|
237
|
+
|
|
238
|
+
// Write the CSR to a temp file
|
|
239
|
+
await writeFile(csrPath, csrPem, { mode: 0o600 });
|
|
240
|
+
|
|
241
|
+
// Validate CSR structure and signature before signing
|
|
242
|
+
try {
|
|
243
|
+
await execa('openssl', ['req', '-verify', '-in', csrPath, '-noout']);
|
|
244
|
+
} catch {
|
|
245
|
+
throw Object.assign(new Error('Invalid CSR: structure or signature verification failed'), {
|
|
246
|
+
statusCode: 400,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The certificate keeps the CSR's subject, and signAgentCsr checks
|
|
251
|
+
// only its shape (and rejects CN=admin). Verify here that the CSR's
|
|
252
|
+
// CN matches exactly what the enrollment token says.
|
|
253
|
+
//
|
|
254
|
+
// Delegated tokens carry a `plugin-agent:<delegating>:<plugin>` label;
|
|
255
|
+
// regular tokens carry the bare label and the panel signs as
|
|
256
|
+
// `agent:<label>`. The agent CLI (B9-aware) builds its CSR to match —
|
|
257
|
+
// older agents that send `/CN=agent:pending` will be rejected here
|
|
258
|
+
// with a clear error, prompting an upgrade.
|
|
259
|
+
const expectedCN = isDelegated ? label : `agent:${label}`;
|
|
260
|
+
const actualCN = await readCsrCN(csrPath);
|
|
261
|
+
if (actualCN !== expectedCN) {
|
|
262
|
+
throw Object.assign(
|
|
263
|
+
new Error(
|
|
264
|
+
`CSR CN must be "${expectedCN}", got "${actualCN ?? 'none'}". ` +
|
|
265
|
+
'If you are upgrading from an older agent, use the new client that ' +
|
|
266
|
+
'looks up the enrollment label before generating the CSR.',
|
|
267
|
+
),
|
|
268
|
+
{ statusCode: 400 },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const serial = randomCertSerial();
|
|
273
|
+
logger.info({ label, isDelegated, serial }, 'Signing enrollment CSR');
|
|
274
|
+
await signAgentCsr(csrPath, certPath, serial, 730);
|
|
275
|
+
|
|
276
|
+
// Read expiry
|
|
277
|
+
const expiry = await readCertExpiry(certPath);
|
|
278
|
+
const expiresAt = expiry?.expiresAt || new Date(Date.now() + 730 * 86400000).toISOString();
|
|
279
|
+
|
|
280
|
+
// Read the signed certificate PEM
|
|
281
|
+
const certPem = await readFile(certPath, 'utf-8');
|
|
282
|
+
|
|
283
|
+
// Read the CA certificate PEM
|
|
284
|
+
const caCertPem = await readFile(`${PKI_DIR}/ca.crt`, 'utf-8');
|
|
285
|
+
|
|
286
|
+
// Add to registry. For delegated enrollments, store the delegation metadata.
|
|
287
|
+
// We reuse the registry loaded at the top of withRegistryLock — the mutex
|
|
288
|
+
// guarantees no concurrent modifications.
|
|
289
|
+
const registryEntry = {
|
|
290
|
+
label,
|
|
291
|
+
serial,
|
|
292
|
+
capabilities: isDelegated ? capabilities || [] : capabilities || ['tunnels:read'],
|
|
293
|
+
allowedSites: isDelegated ? [] : allowedSites || [],
|
|
294
|
+
enrollmentMethod: isDelegated ? 'delegated' : 'hardware-bound',
|
|
295
|
+
createdAt: new Date().toISOString(),
|
|
296
|
+
expiresAt,
|
|
297
|
+
revoked: false,
|
|
298
|
+
};
|
|
299
|
+
if (isDelegated && opts?.delegatedBy) {
|
|
300
|
+
registryEntry.delegatedBy = opts.delegatedBy;
|
|
301
|
+
}
|
|
302
|
+
registry.agents.push(registryEntry);
|
|
303
|
+
await saveAgentRegistry(registry);
|
|
304
|
+
|
|
305
|
+
logger.info({ label, serial, isDelegated }, 'Enrollment CSR signed and agent registered');
|
|
306
|
+
|
|
307
|
+
// Mint a chisel tunnel-server credential for non-delegated agents.
|
|
308
|
+
// Plugin-agents (delegated) participate only in tickets and never open
|
|
309
|
+
// chisel tunnels, so they are skipped. Failure to mint is non-fatal —
|
|
310
|
+
// the agent can re-fetch via `lamaste-agent chisel refresh-credential`
|
|
311
|
+
// once the underlying issue is resolved.
|
|
312
|
+
if (!isDelegated) {
|
|
313
|
+
try {
|
|
314
|
+
await addChiselCredential(label, logger);
|
|
315
|
+
} catch (chErr) {
|
|
316
|
+
logger.warn(
|
|
317
|
+
{ err: chErr, label },
|
|
318
|
+
'Failed to mint chisel credential during enrollment — agent must run `lamaste-agent chisel refresh-credential` once the panel-side issue is resolved',
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { certPem, caCertPem, serial, expiresAt, label };
|
|
324
|
+
} finally {
|
|
325
|
+
// Clean up temp files. The wrapper uses -set_serial, so there is no
|
|
326
|
+
// ca.srl to clean up.
|
|
327
|
+
await unlink(csrPath).catch(() => {});
|
|
328
|
+
await unlink(certPath).catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { loadAgentRegistry, PLUGIN_AGENT_CN_PREFIX, BASE_CAPABILITIES } from './mtls.js';
|
|
3
|
+
import { isRegisteredTicketScope } from './tickets.js';
|
|
4
|
+
import { getStateDb } from './state-db.js';
|
|
5
|
+
|
|
6
|
+
const TOKEN_EXPIRY_MS = 10 * 60 * 1000;
|
|
7
|
+
const CLEANUP_THRESHOLD_MS = 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
// Promise-chain mutex. Each operation is a single SQLite transaction so
|
|
10
|
+
// BEGIN IMMEDIATE already serialises writers — this lock is defensive
|
|
11
|
+
// belt-and-braces around the read-modify-write helpers below. Removable
|
|
12
|
+
// once each operation is collapsed into one transaction.
|
|
13
|
+
let tokenLock = Promise.resolve();
|
|
14
|
+
function withTokenLock(fn) {
|
|
15
|
+
const prev = tokenLock;
|
|
16
|
+
let resolve;
|
|
17
|
+
tokenLock = new Promise((r) => {
|
|
18
|
+
resolve = r;
|
|
19
|
+
});
|
|
20
|
+
return prev.then(fn).finally(resolve);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const COMPARE_KEY = crypto.randomBytes(32);
|
|
24
|
+
|
|
25
|
+
function safeTokenCompare(a, b) {
|
|
26
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
27
|
+
const ha = crypto.createHmac('sha256', COMPARE_KEY).update(a).digest();
|
|
28
|
+
const hb = crypto.createHmac('sha256', COMPARE_KEY).update(b).digest();
|
|
29
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let stmts = null;
|
|
33
|
+
|
|
34
|
+
async function getStmts() {
|
|
35
|
+
if (stmts) return stmts;
|
|
36
|
+
|
|
37
|
+
const db = await getStateDb();
|
|
38
|
+
|
|
39
|
+
stmts = {
|
|
40
|
+
selectActive: db.prepare(`
|
|
41
|
+
SELECT * FROM enrollment_tokens
|
|
42
|
+
WHERE used = 0 AND expires_at > ?
|
|
43
|
+
`),
|
|
44
|
+
insert: db.prepare(`
|
|
45
|
+
INSERT INTO enrollment_tokens
|
|
46
|
+
(token, label, capabilities, allowed_sites, type, delegated_by, scope, created_at, expires_at, used)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
48
|
+
`),
|
|
49
|
+
markUsed: db.prepare(`
|
|
50
|
+
UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE id = ?
|
|
51
|
+
`),
|
|
52
|
+
deleteActiveByLabel: db.prepare(`
|
|
53
|
+
DELETE FROM enrollment_tokens
|
|
54
|
+
WHERE label = ? AND used = 0 AND expires_at > ?
|
|
55
|
+
`),
|
|
56
|
+
deleteStale: db.prepare(`
|
|
57
|
+
DELETE FROM enrollment_tokens WHERE created_at < ?
|
|
58
|
+
`),
|
|
59
|
+
begin: db.prepare('BEGIN IMMEDIATE'),
|
|
60
|
+
commit: db.prepare('COMMIT'),
|
|
61
|
+
rollback: db.prepare('ROLLBACK'),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return stmts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function rowToEntry(row) {
|
|
68
|
+
return {
|
|
69
|
+
id: row.id,
|
|
70
|
+
token: row.token,
|
|
71
|
+
label: row.label,
|
|
72
|
+
capabilities: JSON.parse(row.capabilities),
|
|
73
|
+
allowedSites: JSON.parse(row.allowed_sites),
|
|
74
|
+
type: row.type ?? undefined,
|
|
75
|
+
delegatedBy: row.delegated_by ?? undefined,
|
|
76
|
+
scope: row.scope ?? undefined,
|
|
77
|
+
createdAt: row.created_at,
|
|
78
|
+
expiresAt: row.expires_at,
|
|
79
|
+
used: row.used === 1,
|
|
80
|
+
usedAt: row.used_at ?? undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function purgeStale() {
|
|
85
|
+
const cutoff = new Date(Date.now() - CLEANUP_THRESHOLD_MS).toISOString();
|
|
86
|
+
stmts.deleteStale.run(cutoff);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function createEnrollmentToken(label, capabilities, allowedSites, logger) {
|
|
90
|
+
return withTokenLock(async () => {
|
|
91
|
+
const registry = await loadAgentRegistry();
|
|
92
|
+
const existing = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
93
|
+
if (existing) {
|
|
94
|
+
throw Object.assign(new Error(`Agent certificate with label "${label}" already exists`), {
|
|
95
|
+
statusCode: 409,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await getStmts();
|
|
100
|
+
purgeStale();
|
|
101
|
+
|
|
102
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
103
|
+
const createdAt = new Date().toISOString();
|
|
104
|
+
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
|
|
105
|
+
|
|
106
|
+
stmts.begin.run();
|
|
107
|
+
try {
|
|
108
|
+
// Replace any active (unused, unexpired) token for the same label so a
|
|
109
|
+
// retried installation does not 409 on the create-token side.
|
|
110
|
+
stmts.deleteActiveByLabel.run(label, new Date().toISOString());
|
|
111
|
+
stmts.insert.run(
|
|
112
|
+
token,
|
|
113
|
+
label,
|
|
114
|
+
JSON.stringify(capabilities),
|
|
115
|
+
JSON.stringify(allowedSites),
|
|
116
|
+
null,
|
|
117
|
+
null,
|
|
118
|
+
null,
|
|
119
|
+
createdAt,
|
|
120
|
+
expiresAt,
|
|
121
|
+
);
|
|
122
|
+
stmts.commit.run();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
stmts.rollback.run();
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
logger.info({ label, expiresAt }, 'Created enrollment token');
|
|
129
|
+
return { token, label, expiresAt };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function validateAndConsumeToken(token) {
|
|
134
|
+
return withTokenLock(async () => {
|
|
135
|
+
await getStmts();
|
|
136
|
+
purgeStale();
|
|
137
|
+
|
|
138
|
+
// Linear scan over active tokens with timing-safe HMAC compare. SQLite
|
|
139
|
+
// gives us O(log N) on indexed columns, but the timing-safe comparison
|
|
140
|
+
// is the security-relevant part — an indexed lookup would leak which
|
|
141
|
+
// candidate token was found via differential timing. Keep the scan.
|
|
142
|
+
const rows = stmts.selectActive.all(new Date().toISOString());
|
|
143
|
+
const entryRow = rows.find((r) => safeTokenCompare(r.token, token));
|
|
144
|
+
|
|
145
|
+
if (!entryRow) {
|
|
146
|
+
throw Object.assign(new Error('Invalid enrollment token'), { statusCode: 401 });
|
|
147
|
+
}
|
|
148
|
+
const entry = rowToEntry(entryRow);
|
|
149
|
+
|
|
150
|
+
if (entry.used) {
|
|
151
|
+
throw Object.assign(new Error('Enrollment token has already been used'), { statusCode: 401 });
|
|
152
|
+
}
|
|
153
|
+
if (new Date(entry.expiresAt).getTime() < Date.now()) {
|
|
154
|
+
throw Object.assign(new Error('Enrollment token has expired'), { statusCode: 401 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// For delegated tokens, re-check that the delegating agent is still valid.
|
|
158
|
+
// The agent may have been revoked between token creation and consumption.
|
|
159
|
+
if (entry.type === 'delegated' && entry.delegatedBy) {
|
|
160
|
+
const registry = await loadAgentRegistry();
|
|
161
|
+
const delegator = registry.agents.find((a) => a.label === entry.delegatedBy && !a.revoked);
|
|
162
|
+
if (!delegator) {
|
|
163
|
+
throw Object.assign(new Error('Invalid enrollment token'), { statusCode: 401 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
stmts.markUsed.run(new Date().toISOString(), entry.id);
|
|
168
|
+
|
|
169
|
+
/** @type {{ label: string, capabilities: string[], allowedSites: string[], type?: string, delegatedBy?: string, scope?: string }} */
|
|
170
|
+
const result = {
|
|
171
|
+
label: entry.label,
|
|
172
|
+
capabilities: entry.capabilities,
|
|
173
|
+
allowedSites: entry.allowedSites,
|
|
174
|
+
};
|
|
175
|
+
if (entry.type) result.type = entry.type;
|
|
176
|
+
if (entry.delegatedBy) result.delegatedBy = entry.delegatedBy;
|
|
177
|
+
if (entry.scope) result.scope = entry.scope;
|
|
178
|
+
return result;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function createDelegatedEnrollmentToken(
|
|
183
|
+
delegatingLabel,
|
|
184
|
+
scope,
|
|
185
|
+
pluginAgentLabel,
|
|
186
|
+
logger,
|
|
187
|
+
) {
|
|
188
|
+
// Validate that the scope is a registered ticket scope, not a base capability.
|
|
189
|
+
// Base capabilities (tunnels:read, services:write, etc.) must never be
|
|
190
|
+
// delegated through enrollment — they are admin-assigned per-agent. These
|
|
191
|
+
// checks run outside the token lock because scope validation is independent
|
|
192
|
+
// of token operations and avoids nesting the ticket lock inside the token
|
|
193
|
+
// lock.
|
|
194
|
+
if (BASE_CAPABILITIES.includes(scope)) {
|
|
195
|
+
throw Object.assign(new Error('Scope conflicts with a base capability'), {
|
|
196
|
+
statusCode: 400,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const isTicketScope = await isRegisteredTicketScope(scope);
|
|
201
|
+
if (!isTicketScope) {
|
|
202
|
+
throw Object.assign(new Error(`Scope "${scope}" is not a registered ticket scope`), {
|
|
203
|
+
statusCode: 400,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return withTokenLock(async () => {
|
|
208
|
+
const registry = await loadAgentRegistry();
|
|
209
|
+
const delegatingAgent = registry.agents.find((a) => a.label === delegatingLabel && !a.revoked);
|
|
210
|
+
if (!delegatingAgent) {
|
|
211
|
+
throw Object.assign(new Error(`Delegating agent "${delegatingLabel}" not found or revoked`), {
|
|
212
|
+
statusCode: 404,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fullLabel = `${PLUGIN_AGENT_CN_PREFIX}${delegatingLabel}:${pluginAgentLabel}`;
|
|
217
|
+
|
|
218
|
+
const existing = registry.agents.find((a) => a.label === fullLabel && !a.revoked);
|
|
219
|
+
if (existing) {
|
|
220
|
+
throw Object.assign(
|
|
221
|
+
new Error(
|
|
222
|
+
`Plugin agent certificate with label "${pluginAgentLabel}" for delegator "${delegatingLabel}" already exists`,
|
|
223
|
+
),
|
|
224
|
+
{ statusCode: 409 },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await getStmts();
|
|
229
|
+
purgeStale();
|
|
230
|
+
|
|
231
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
232
|
+
const createdAt = new Date().toISOString();
|
|
233
|
+
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
|
|
234
|
+
|
|
235
|
+
stmts.begin.run();
|
|
236
|
+
try {
|
|
237
|
+
stmts.deleteActiveByLabel.run(fullLabel, new Date().toISOString());
|
|
238
|
+
stmts.insert.run(
|
|
239
|
+
token,
|
|
240
|
+
fullLabel,
|
|
241
|
+
JSON.stringify([scope]),
|
|
242
|
+
JSON.stringify([]),
|
|
243
|
+
'delegated',
|
|
244
|
+
delegatingLabel,
|
|
245
|
+
scope,
|
|
246
|
+
createdAt,
|
|
247
|
+
expiresAt,
|
|
248
|
+
);
|
|
249
|
+
stmts.commit.run();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
stmts.rollback.run();
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
logger.info(
|
|
256
|
+
{ delegatingLabel, pluginAgentLabel, scope, expiresAt },
|
|
257
|
+
'Created delegated enrollment token',
|
|
258
|
+
);
|
|
259
|
+
return { token, pluginAgentLabel, expiresAt };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function lookupToken(token) {
|
|
264
|
+
return withTokenLock(async () => {
|
|
265
|
+
await getStmts();
|
|
266
|
+
purgeStale();
|
|
267
|
+
|
|
268
|
+
const rows = stmts.selectActive.all(new Date().toISOString());
|
|
269
|
+
const entryRow = rows.find((r) => safeTokenCompare(r.token, token));
|
|
270
|
+
|
|
271
|
+
if (!entryRow) {
|
|
272
|
+
throw Object.assign(new Error('Invalid enrollment token'), { statusCode: 401 });
|
|
273
|
+
}
|
|
274
|
+
const entry = rowToEntry(entryRow);
|
|
275
|
+
|
|
276
|
+
if (entry.used) {
|
|
277
|
+
throw Object.assign(new Error('Enrollment token has already been used'), { statusCode: 401 });
|
|
278
|
+
}
|
|
279
|
+
if (new Date(entry.expiresAt).getTime() < Date.now()) {
|
|
280
|
+
throw Object.assign(new Error('Enrollment token has expired'), { statusCode: 401 });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @type {{ label: string, type?: string }} */
|
|
284
|
+
const result = { label: entry.label };
|
|
285
|
+
if (entry.type) result.type = entry.type;
|
|
286
|
+
return result;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function revokeEnrollmentToken(label, logger) {
|
|
291
|
+
return withTokenLock(async () => {
|
|
292
|
+
await getStmts();
|
|
293
|
+
purgeStale();
|
|
294
|
+
|
|
295
|
+
const result = stmts.deleteActiveByLabel.run(label, new Date().toISOString());
|
|
296
|
+
const revoked = result.changes > 0;
|
|
297
|
+
if (revoked) {
|
|
298
|
+
logger.info({ label }, 'Revoked unused enrollment token');
|
|
299
|
+
}
|
|
300
|
+
return { revoked };
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exports the static site filesystem helpers from
|
|
3
|
+
* `@lamalibre/lamaste/server`. They need no privileges: the web root belongs
|
|
4
|
+
* to the lamaste user (group www-data, for nginx).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
SITES_ROOT,
|
|
9
|
+
ALLOWED_EXTENSIONS,
|
|
10
|
+
validateFileExtension,
|
|
11
|
+
validatePath,
|
|
12
|
+
getSiteRoot,
|
|
13
|
+
createSiteDirectory,
|
|
14
|
+
removeSiteDirectory,
|
|
15
|
+
listFiles,
|
|
16
|
+
saveUploadedFile,
|
|
17
|
+
deleteFile,
|
|
18
|
+
getSiteSize,
|
|
19
|
+
} from '@lamalibre/lamaste/server';
|