@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,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side mTLS certificate and agent registry management.
|
|
3
|
+
*
|
|
4
|
+
* Backed by SQLite (`state.db`, table `agents`) per
|
|
5
|
+
* docs/decisions/sqlite-migration.md §3d. The companion `revoked_certs` table
|
|
6
|
+
* created by the same `0004_agents.sql` migration is owned by `lib/revocation.js`.
|
|
7
|
+
*
|
|
8
|
+
* Consumers — the mTLS middleware (every request), enrollment.js, tickets.js,
|
|
9
|
+
* plugins.js, csr-signing.js, the chisel credential migration, and several
|
|
10
|
+
* routes — call `loadAgentRegistry` / `saveAgentRegistry`; they never touch
|
|
11
|
+
* SQLite directly.
|
|
12
|
+
*/
|
|
13
|
+
import { execa } from 'execa';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { access, constants, mkdir } from 'node:fs/promises';
|
|
16
|
+
import {
|
|
17
|
+
BASE_CAPABILITIES as CORE_BASE_CAPABILITIES,
|
|
18
|
+
PLUGIN_AGENT_CN_PREFIX as CORE_PLUGIN_AGENT_CN_PREFIX,
|
|
19
|
+
} from '@lamalibre/lamaste';
|
|
20
|
+
import { addToRevocationList } from './revocation.js';
|
|
21
|
+
import { releaseAgentTunnels } from '@lamalibre/lamaste/server';
|
|
22
|
+
import { tunnelNginxDeps, tunnelChiselDeps, tunnelStateDeps } from './tunnel-deps.js';
|
|
23
|
+
import {
|
|
24
|
+
addChiselCredential,
|
|
25
|
+
removeChiselCredential,
|
|
26
|
+
rotateChiselCredential as rotateChiselCredentialLib,
|
|
27
|
+
} from './chisel-users.js';
|
|
28
|
+
import { getStateDb } from './state-db.js';
|
|
29
|
+
import { randomCertSerial, signAgentCsr } from './pki-sign.js';
|
|
30
|
+
|
|
31
|
+
const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
|
|
32
|
+
|
|
33
|
+
// Promise-chain mutex to serialize agent registry modifications.
|
|
34
|
+
//
|
|
35
|
+
// SQLite's BEGIN IMMEDIATE serialises a single write transaction, but many
|
|
36
|
+
// of the exported operations are read-modify-write sequences spanning
|
|
37
|
+
// multiple statements (load → mutate → save). This lock keeps each such
|
|
38
|
+
// sequence atomic. Tracking as a follow-up: collapse each sequence into a
|
|
39
|
+
// single transaction, then drop the mutex.
|
|
40
|
+
let registryLock = Promise.resolve();
|
|
41
|
+
export function withRegistryLock(fn) {
|
|
42
|
+
const prev = registryLock;
|
|
43
|
+
let resolve;
|
|
44
|
+
registryLock = new Promise((r) => {
|
|
45
|
+
resolve = r;
|
|
46
|
+
});
|
|
47
|
+
return prev.then(fn).finally(resolve);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute the SHA-256 fingerprint of a PEM-encoded certificate.
|
|
52
|
+
*
|
|
53
|
+
* Used by the cert-lifecycle audit logs so an operator inspecting
|
|
54
|
+
* journalctl can correlate issued/revoked certs with what their clients
|
|
55
|
+
* present. Returns the lower-case hex digest with no separators (matches
|
|
56
|
+
* `openssl x509 -fingerprint -sha256` minus the colons).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} certPem - PEM-encoded X.509 certificate
|
|
59
|
+
* @returns {string | null} hex digest, or null if PEM cannot be decoded
|
|
60
|
+
*/
|
|
61
|
+
export function fingerprintCertPem(certPem) {
|
|
62
|
+
if (typeof certPem !== 'string' || certPem.length === 0) return null;
|
|
63
|
+
const match = certPem.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/);
|
|
64
|
+
if (!match) return null;
|
|
65
|
+
const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64');
|
|
66
|
+
if (der.length === 0) return null;
|
|
67
|
+
return crypto.createHash('sha256').update(der).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read the expiry date from a certificate file using openssl.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} certPath - Absolute path to the certificate file
|
|
74
|
+
* @returns {{ expiresAt: string, daysUntilExpiry: number } | null}
|
|
75
|
+
*/
|
|
76
|
+
export async function readCertExpiry(certPath) {
|
|
77
|
+
try {
|
|
78
|
+
const { stdout } = await execa('openssl', ['x509', '-in', certPath, '-enddate', '-noout']);
|
|
79
|
+
const match = stdout.match(/notAfter=(.+)/);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
|
|
82
|
+
const expiryDate = new Date(match[1]);
|
|
83
|
+
if (isNaN(expiryDate.getTime())) return null;
|
|
84
|
+
|
|
85
|
+
const daysUntilExpiry = Math.floor((expiryDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
expiresAt: expiryDate.toISOString(),
|
|
89
|
+
daysUntilExpiry,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get mTLS certificate info (CA and client certs).
|
|
98
|
+
*
|
|
99
|
+
* @returns {Array<{ type: string, domain: null, expiresAt: string, daysUntilExpiry: number, path: string, expiringSoon: boolean }>}
|
|
100
|
+
*/
|
|
101
|
+
export async function getMtlsCerts() {
|
|
102
|
+
const certs = [];
|
|
103
|
+
|
|
104
|
+
const certFiles = [
|
|
105
|
+
{ type: 'mtls-ca', filename: 'ca.crt' },
|
|
106
|
+
{ type: 'mtls-client', filename: 'client.crt' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const { type, filename } of certFiles) {
|
|
110
|
+
const certPath = `${PKI_DIR}/${filename}`;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await access(certPath, constants.R_OK);
|
|
114
|
+
} catch {
|
|
115
|
+
// File doesn't exist or not readable — skip silently
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const expiry = await readCertExpiry(certPath);
|
|
120
|
+
if (!expiry) continue;
|
|
121
|
+
|
|
122
|
+
certs.push({
|
|
123
|
+
type,
|
|
124
|
+
domain: null,
|
|
125
|
+
expiresAt: expiry.expiresAt,
|
|
126
|
+
daysUntilExpiry: expiry.daysUntilExpiry,
|
|
127
|
+
path: certPath,
|
|
128
|
+
expiringSoon: expiry.daysUntilExpiry <= 30,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return certs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rotate the mTLS client certificate (admin's own P12).
|
|
137
|
+
*
|
|
138
|
+
* SECURITY (B9): the panel does not issue admin certificates. Admin cert
|
|
139
|
+
* rotation lives only in `lamaste-server reset-admin`, run on the server
|
|
140
|
+
* console. This stub returns a clear, actionable 503 so the existing route
|
|
141
|
+
* surfaces a useful error.
|
|
142
|
+
*
|
|
143
|
+
* @param {import('pino').Logger} logger
|
|
144
|
+
* @returns {Promise<never>}
|
|
145
|
+
*/
|
|
146
|
+
export async function rotateClientCert(logger) {
|
|
147
|
+
logger.warn(
|
|
148
|
+
'Refused mTLS client cert rotation — panel-side admin cert rotation is disabled (B9). Use `sudo lamaste-server reset-admin` on the server.',
|
|
149
|
+
);
|
|
150
|
+
throw Object.assign(
|
|
151
|
+
new Error(
|
|
152
|
+
'Panel-initiated admin certificate rotation is disabled for security. ' +
|
|
153
|
+
'Run `sudo lamaste-server reset-admin` on the server console to issue a new admin certificate.',
|
|
154
|
+
),
|
|
155
|
+
{ statusCode: 503 },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the path to the client.p12 file.
|
|
161
|
+
*
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function getP12Path() {
|
|
165
|
+
return `${PKI_DIR}/client.p12`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Agent certificate management
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
const AGENTS_DIR = `${PKI_DIR}/agents`;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Re-exported from @lamalibre/lamaste core.
|
|
176
|
+
* CN prefix for plugin-agent certificates.
|
|
177
|
+
*/
|
|
178
|
+
export const PLUGIN_AGENT_CN_PREFIX = CORE_PLUGIN_AGENT_CN_PREFIX;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Re-exported from @lamalibre/lamaste core.
|
|
182
|
+
* Base capabilities that can be assigned to agent certificates.
|
|
183
|
+
*/
|
|
184
|
+
export const BASE_CAPABILITIES = CORE_BASE_CAPABILITIES;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Plugin-contributed capabilities, loaded at startup.
|
|
188
|
+
* @type {string[]}
|
|
189
|
+
*/
|
|
190
|
+
let pluginCapabilities = [];
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Ticket-scope-contributed capabilities, loaded at startup.
|
|
194
|
+
* @type {string[]}
|
|
195
|
+
*/
|
|
196
|
+
let ticketScopeCapabilities = [];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Set plugin-contributed capabilities.
|
|
200
|
+
* Called at startup after loading plugin manifests.
|
|
201
|
+
*
|
|
202
|
+
* @param {string[]} caps - Capability strings from plugins
|
|
203
|
+
*/
|
|
204
|
+
export function setPluginCapabilities(caps) {
|
|
205
|
+
pluginCapabilities = [...new Set(caps)];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Set ticket-scope-contributed capabilities.
|
|
210
|
+
* Called at startup after loading ticket scope registry.
|
|
211
|
+
*
|
|
212
|
+
* @param {string[]} caps - Capability strings from ticket scopes
|
|
213
|
+
*/
|
|
214
|
+
export function setTicketScopeCapabilitiesOnMtls(caps) {
|
|
215
|
+
ticketScopeCapabilities = [...new Set(caps)];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get the full list of valid capabilities (base + plugin + ticket scope).
|
|
220
|
+
*
|
|
221
|
+
* @returns {string[]}
|
|
222
|
+
*/
|
|
223
|
+
export function getValidCapabilities() {
|
|
224
|
+
return [...BASE_CAPABILITIES, ...pluginCapabilities, ...ticketScopeCapabilities];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- SQLite prepared-statement bundle (lazy init) ---
|
|
228
|
+
|
|
229
|
+
let stmts = null;
|
|
230
|
+
|
|
231
|
+
async function getStmts() {
|
|
232
|
+
if (stmts) return stmts;
|
|
233
|
+
|
|
234
|
+
const db = await getStateDb();
|
|
235
|
+
|
|
236
|
+
stmts = {
|
|
237
|
+
db,
|
|
238
|
+
|
|
239
|
+
selectAll: db.prepare('SELECT * FROM agents'),
|
|
240
|
+
|
|
241
|
+
insert: db.prepare(`
|
|
242
|
+
INSERT INTO agents
|
|
243
|
+
(label, serial, capabilities, allowed_sites, enrollment_method,
|
|
244
|
+
delegated_by, created_at, expires_at, revoked, revoked_at)
|
|
245
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
246
|
+
`),
|
|
247
|
+
|
|
248
|
+
deleteAll: db.prepare('DELETE FROM agents'),
|
|
249
|
+
|
|
250
|
+
begin: db.prepare('BEGIN IMMEDIATE'),
|
|
251
|
+
commit: db.prepare('COMMIT'),
|
|
252
|
+
rollback: db.prepare('ROLLBACK'),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return stmts;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Row → JS object helper ---
|
|
259
|
+
//
|
|
260
|
+
// JSON-typed columns (capabilities, allowed_sites) are JSON-encoded TEXT in
|
|
261
|
+
// storage. They round-trip through JSON.parse/stringify so callers see the
|
|
262
|
+
// shape they expect. Optional columns (revoked_at, delegated_by) are
|
|
263
|
+
// converted from NULL to absent properties so consumers can treat unset
|
|
264
|
+
// fields with `if (entry.field)` rather than tri-state checks.
|
|
265
|
+
|
|
266
|
+
function rowToAgent(row) {
|
|
267
|
+
const agent = {
|
|
268
|
+
label: row.label,
|
|
269
|
+
serial: row.serial,
|
|
270
|
+
capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
|
|
271
|
+
allowedSites: row.allowed_sites ? JSON.parse(row.allowed_sites) : [],
|
|
272
|
+
enrollmentMethod: row.enrollment_method,
|
|
273
|
+
createdAt: row.created_at,
|
|
274
|
+
expiresAt: row.expires_at,
|
|
275
|
+
revoked: row.revoked === 1,
|
|
276
|
+
};
|
|
277
|
+
if (row.revoked_at != null) agent.revokedAt = row.revoked_at;
|
|
278
|
+
if (row.delegated_by != null) agent.delegatedBy = row.delegated_by;
|
|
279
|
+
return agent;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function nullableString(value) {
|
|
283
|
+
return value === undefined || value === null ? null : value;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Load the agent registry.
|
|
288
|
+
* Returns `{ agents: [] }` if the table is empty.
|
|
289
|
+
*
|
|
290
|
+
* Hot path: called on every authenticated mTLS request indirectly via
|
|
291
|
+
* `getAgentCapabilities` / `getAgentAllowedSites`. `SELECT * FROM agents`
|
|
292
|
+
* runs every call — no in-memory cache. WAL keeps it cheap.
|
|
293
|
+
*
|
|
294
|
+
* @returns {Promise<{ agents: Array }>}
|
|
295
|
+
*/
|
|
296
|
+
export async function loadAgentRegistry() {
|
|
297
|
+
const s = await getStmts();
|
|
298
|
+
const rows = s.selectAll.all();
|
|
299
|
+
return { agents: rows.map(rowToAgent) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Atomically save the agent registry to disk.
|
|
304
|
+
*
|
|
305
|
+
* Accepts a `{ agents: [...] }` shape and replaces the entire registry in
|
|
306
|
+
* one BEGIN IMMEDIATE transaction (DELETE + INSERTs) so observers see either
|
|
307
|
+
* the old or the new state, never a partial write.
|
|
308
|
+
*
|
|
309
|
+
* @param {{ agents: Array }} data
|
|
310
|
+
*/
|
|
311
|
+
export async function saveAgentRegistry(data) {
|
|
312
|
+
const s = await getStmts();
|
|
313
|
+
const rawList = Array.isArray(data?.agents) ? data.agents : [];
|
|
314
|
+
|
|
315
|
+
// Dedup by label, keeping the last occurrence. Required because the
|
|
316
|
+
// load-modify-save pattern (loadAgentRegistry → push new entry → save) can
|
|
317
|
+
// produce two same-label rows if the prior row exists revoked — the caller
|
|
318
|
+
// filters by `find(a => a.label === l && !a.revoked)` so it doesn't see
|
|
319
|
+
// the revoked row, but its push appends alongside it. SQLite's PK on
|
|
320
|
+
// label rejects that two-row shape; collapse it here so all such callers
|
|
321
|
+
// work without each having to remove-then-push.
|
|
322
|
+
const byLabel = new Map();
|
|
323
|
+
for (const a of rawList) {
|
|
324
|
+
if (typeof a?.label === 'string') byLabel.set(a.label, a);
|
|
325
|
+
}
|
|
326
|
+
const list = [...byLabel.values()];
|
|
327
|
+
|
|
328
|
+
s.begin.run();
|
|
329
|
+
try {
|
|
330
|
+
s.deleteAll.run();
|
|
331
|
+
for (const a of list) {
|
|
332
|
+
s.insert.run(
|
|
333
|
+
a.label,
|
|
334
|
+
a.serial,
|
|
335
|
+
JSON.stringify(a.capabilities ?? []),
|
|
336
|
+
JSON.stringify(a.allowedSites ?? []),
|
|
337
|
+
a.enrollmentMethod ?? 'p12',
|
|
338
|
+
nullableString(a.delegatedBy),
|
|
339
|
+
a.createdAt,
|
|
340
|
+
a.expiresAt,
|
|
341
|
+
a.revoked ? 1 : 0,
|
|
342
|
+
nullableString(a.revokedAt),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
s.commit.run();
|
|
346
|
+
} catch (err) {
|
|
347
|
+
s.rollback.run();
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get the path to an agent's PKCS12 bundle.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} label
|
|
356
|
+
* @returns {string}
|
|
357
|
+
*/
|
|
358
|
+
export function getAgentP12Path(label) {
|
|
359
|
+
return `${AGENTS_DIR}/${label}/client.p12`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Generate an agent-scoped client certificate.
|
|
364
|
+
*
|
|
365
|
+
* Creates a new RSA key, CSR, signs with the existing CA, and packages
|
|
366
|
+
* the result as a PKCS12 bundle (legacy PBE-SHA1-3DES for macOS compat).
|
|
367
|
+
*
|
|
368
|
+
* @param {string} label - Unique agent label (e.g. "macbook-pro")
|
|
369
|
+
* @param {import('pino').Logger} logger
|
|
370
|
+
* @param {string[]} [capabilities] - Capability list (defaults to ['tunnels:read'])
|
|
371
|
+
* @param {string[]} [allowedSites] - Allowed site labels (defaults to [])
|
|
372
|
+
* @returns {Promise<{ label: string, p12Password: string, serial: string, expiresAt: string }>}
|
|
373
|
+
*/
|
|
374
|
+
export async function generateAgentCert(label, logger, capabilities, allowedSites) {
|
|
375
|
+
return withRegistryLock(async () => {
|
|
376
|
+
// Check registry for duplicate (non-revoked) label
|
|
377
|
+
const registry = await loadAgentRegistry();
|
|
378
|
+
const existing = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
379
|
+
if (existing) {
|
|
380
|
+
throw Object.assign(new Error(`Agent certificate with label "${label}" already exists`), {
|
|
381
|
+
statusCode: 409,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Verify CA key exists
|
|
386
|
+
try {
|
|
387
|
+
await access(`${PKI_DIR}/ca.key`, constants.R_OK);
|
|
388
|
+
} catch {
|
|
389
|
+
throw Object.assign(new Error('CA key not found — cannot sign new certificate'), {
|
|
390
|
+
statusCode: 500,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const agentDir = `${AGENTS_DIR}/${label}`;
|
|
395
|
+
const keyPath = `${agentDir}/client.key`;
|
|
396
|
+
const csrPath = `${agentDir}/client.csr`;
|
|
397
|
+
const certPath = `${agentDir}/client.crt`;
|
|
398
|
+
const p12Path = `${agentDir}/client.p12`;
|
|
399
|
+
|
|
400
|
+
const p12Password = crypto.randomBytes(16).toString('hex');
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
// 1. Create the agent's directory (the panel owns the PKI directory)
|
|
404
|
+
logger.info({ label }, 'Creating agent certificate directory');
|
|
405
|
+
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
|
406
|
+
|
|
407
|
+
// 2. Generate 4096-bit RSA key
|
|
408
|
+
logger.info({ label }, 'Generating agent private key');
|
|
409
|
+
await execa('openssl', ['genrsa', '-out', keyPath, '4096']);
|
|
410
|
+
|
|
411
|
+
// 3. Create CSR with agent-scoped CN
|
|
412
|
+
logger.info({ label }, 'Creating certificate signing request');
|
|
413
|
+
await execa('openssl', [
|
|
414
|
+
'req',
|
|
415
|
+
'-new',
|
|
416
|
+
'-key',
|
|
417
|
+
keyPath,
|
|
418
|
+
'-out',
|
|
419
|
+
csrPath,
|
|
420
|
+
'-subj',
|
|
421
|
+
`/CN=agent:${label}/O=Lamaste`,
|
|
422
|
+
]);
|
|
423
|
+
|
|
424
|
+
// 4. Sign with CA (2-year validity)
|
|
425
|
+
logger.info({ label }, 'Signing certificate with CA');
|
|
426
|
+
await signAgentCsr(csrPath, certPath, randomCertSerial(), 730);
|
|
427
|
+
|
|
428
|
+
// 5. Create PKCS12 bundle (legacy flags for macOS compatibility)
|
|
429
|
+
logger.info({ label }, 'Creating PKCS12 bundle');
|
|
430
|
+
await execa(
|
|
431
|
+
'openssl',
|
|
432
|
+
[
|
|
433
|
+
'pkcs12',
|
|
434
|
+
'-export',
|
|
435
|
+
'-keypbe',
|
|
436
|
+
'PBE-SHA1-3DES',
|
|
437
|
+
'-certpbe',
|
|
438
|
+
'PBE-SHA1-3DES',
|
|
439
|
+
'-macalg',
|
|
440
|
+
'sha1',
|
|
441
|
+
'-out',
|
|
442
|
+
p12Path,
|
|
443
|
+
'-inkey',
|
|
444
|
+
keyPath,
|
|
445
|
+
'-in',
|
|
446
|
+
certPath,
|
|
447
|
+
'-certfile',
|
|
448
|
+
`${PKI_DIR}/ca.crt`,
|
|
449
|
+
'-passout',
|
|
450
|
+
'stdin',
|
|
451
|
+
],
|
|
452
|
+
{ input: p12Password },
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// 7. Read the serial number from the signed certificate
|
|
456
|
+
const { stdout: serialOut } = await execa('openssl', [
|
|
457
|
+
'x509',
|
|
458
|
+
'-in',
|
|
459
|
+
certPath,
|
|
460
|
+
'-serial',
|
|
461
|
+
'-noout',
|
|
462
|
+
]);
|
|
463
|
+
const serialMatch = serialOut.match(/serial=([A-Fa-f0-9]+)/);
|
|
464
|
+
const serial = serialMatch ? serialMatch[1] : '';
|
|
465
|
+
|
|
466
|
+
// 8. Read expiry via existing readCertExpiry
|
|
467
|
+
const expiry = await readCertExpiry(certPath);
|
|
468
|
+
const expiresAt = expiry?.expiresAt || new Date(Date.now() + 730 * 86400000).toISOString();
|
|
469
|
+
|
|
470
|
+
// 9. Clean up CSR
|
|
471
|
+
await execa('rm', ['-f', csrPath]);
|
|
472
|
+
|
|
473
|
+
// 10. Set file permissions (lamaste owns these, no sudo needed)
|
|
474
|
+
await execa('chmod', ['600', keyPath]);
|
|
475
|
+
await execa('chmod', ['644', certPath]);
|
|
476
|
+
await execa('chmod', ['600', p12Path]);
|
|
477
|
+
|
|
478
|
+
// 11. Add to registry (reuse the registry loaded at the top of withRegistryLock —
|
|
479
|
+
// the mutex guarantees no concurrent modifications)
|
|
480
|
+
registry.agents.push({
|
|
481
|
+
label,
|
|
482
|
+
serial,
|
|
483
|
+
capabilities: capabilities || ['tunnels:read'],
|
|
484
|
+
allowedSites: allowedSites || [],
|
|
485
|
+
enrollmentMethod: 'p12',
|
|
486
|
+
createdAt: new Date().toISOString(),
|
|
487
|
+
expiresAt,
|
|
488
|
+
revoked: false,
|
|
489
|
+
});
|
|
490
|
+
await saveAgentRegistry(registry);
|
|
491
|
+
|
|
492
|
+
// 12. Mint a chisel tunnel-server credential. Plugin-agents do not open
|
|
493
|
+
// chisel tunnels — they participate only in the ticket bus — so they are
|
|
494
|
+
// skipped here. If chisel credential provisioning fails, the agent cert
|
|
495
|
+
// is still usable for panel API auth; we surface a warning in the result.
|
|
496
|
+
const result = { label, p12Password, serial, expiresAt };
|
|
497
|
+
if (!label.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
|
|
498
|
+
try {
|
|
499
|
+
const chisel = await addChiselCredential(label, logger);
|
|
500
|
+
result.chiselCredential = { user: chisel.user, password: chisel.password };
|
|
501
|
+
if (!chisel.restartOk) {
|
|
502
|
+
result.chiselWarning =
|
|
503
|
+
'Chisel credential created but service restart failed; ' +
|
|
504
|
+
'tunnel auth will activate on the next chisel restart. ' +
|
|
505
|
+
`Details: ${chisel.restartError}`;
|
|
506
|
+
}
|
|
507
|
+
} catch (chErr) {
|
|
508
|
+
logger.warn(
|
|
509
|
+
{ err: chErr, label },
|
|
510
|
+
'Failed to mint chisel credential for new agent — agent will not be able to open tunnels until this is fixed',
|
|
511
|
+
);
|
|
512
|
+
result.chiselWarning =
|
|
513
|
+
'Failed to mint chisel tunnel credential. ' +
|
|
514
|
+
'Re-run `lamaste-server chisel rotate-credential --label ' +
|
|
515
|
+
label +
|
|
516
|
+
'` once the underlying issue is resolved.';
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return result;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// Clean up on failure
|
|
523
|
+
logger.error({ err, label }, 'Agent certificate generation failed, cleaning up');
|
|
524
|
+
await execa('rm', ['-rf', agentDir]).catch(() => {});
|
|
525
|
+
|
|
526
|
+
if (err.statusCode) throw err;
|
|
527
|
+
throw Object.assign(
|
|
528
|
+
new Error(`Agent certificate generation failed: ${err.stderr || err.message}`),
|
|
529
|
+
{ statusCode: 500 },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* List all agent certificates with expiry status.
|
|
537
|
+
*
|
|
538
|
+
* @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean }>>}
|
|
539
|
+
*/
|
|
540
|
+
export async function listAgentCerts() {
|
|
541
|
+
const registry = await loadAgentRegistry();
|
|
542
|
+
|
|
543
|
+
return registry.agents.map((agent) => {
|
|
544
|
+
let expiringSoon = false;
|
|
545
|
+
if (!agent.revoked && agent.expiresAt) {
|
|
546
|
+
const daysUntilExpiry = Math.floor(
|
|
547
|
+
(new Date(agent.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
|
548
|
+
);
|
|
549
|
+
expiringSoon = daysUntilExpiry <= 30;
|
|
550
|
+
}
|
|
551
|
+
const isPluginAgent = agent.label.startsWith(PLUGIN_AGENT_CN_PREFIX);
|
|
552
|
+
const entry = {
|
|
553
|
+
...agent,
|
|
554
|
+
capabilities: agent.capabilities || (isPluginAgent ? [] : ['tunnels:read']),
|
|
555
|
+
enrollmentMethod: agent.enrollmentMethod || 'p12',
|
|
556
|
+
expiringSoon,
|
|
557
|
+
};
|
|
558
|
+
if (isPluginAgent) {
|
|
559
|
+
entry.certType = 'plugin-agent';
|
|
560
|
+
}
|
|
561
|
+
return entry;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Get capabilities for a specific agent or plugin-agent by label.
|
|
567
|
+
*
|
|
568
|
+
* For plugin-agents, the label in the registry is the full
|
|
569
|
+
* `plugin-agent:<delegatingLabel>:<pluginAgentLabel>` string.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} label
|
|
572
|
+
* @returns {Promise<string[]>}
|
|
573
|
+
*/
|
|
574
|
+
export async function getAgentCapabilities(label) {
|
|
575
|
+
const registry = await loadAgentRegistry();
|
|
576
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
577
|
+
// Plugin-agents have no default capabilities — return empty if not found
|
|
578
|
+
if (!agent) {
|
|
579
|
+
return label.startsWith(PLUGIN_AGENT_CN_PREFIX) ? [] : ['tunnels:read'];
|
|
580
|
+
}
|
|
581
|
+
const stored =
|
|
582
|
+
agent.capabilities || (label.startsWith(PLUGIN_AGENT_CN_PREFIX) ? [] : ['tunnels:read']);
|
|
583
|
+
// Filter against currently valid capabilities so disabled-plugin caps are excluded
|
|
584
|
+
const valid = getValidCapabilities();
|
|
585
|
+
return stored.filter((c) => valid.includes(c));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Update capabilities for an agent certificate.
|
|
590
|
+
*
|
|
591
|
+
* @param {string} label
|
|
592
|
+
* @param {string[]} capabilities
|
|
593
|
+
* @returns {Promise<{ ok: true, label: string, capabilities: string[] }>}
|
|
594
|
+
*/
|
|
595
|
+
export async function updateAgentCapabilities(label, capabilities) {
|
|
596
|
+
return withRegistryLock(async () => {
|
|
597
|
+
const registry = await loadAgentRegistry();
|
|
598
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
599
|
+
|
|
600
|
+
if (!agent) {
|
|
601
|
+
throw Object.assign(new Error(`Agent certificate "${label}" not found`), { statusCode: 404 });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Validate all capabilities against base + plugin capabilities
|
|
605
|
+
const validCaps = getValidCapabilities();
|
|
606
|
+
for (const cap of capabilities) {
|
|
607
|
+
if (!validCaps.includes(cap)) {
|
|
608
|
+
throw Object.assign(new Error(`Invalid capability: ${cap}`), { statusCode: 400 });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Ensure tunnels:read is always present for regular agents
|
|
613
|
+
// Plugin-agents only get explicitly assigned capabilities
|
|
614
|
+
if (!label.startsWith('plugin-agent:') && !capabilities.includes('tunnels:read')) {
|
|
615
|
+
capabilities.unshift('tunnels:read');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
agent.capabilities = capabilities;
|
|
619
|
+
await saveAgentRegistry(registry);
|
|
620
|
+
|
|
621
|
+
return { ok: true, label, capabilities };
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Get allowed sites for a specific agent or plugin-agent by label.
|
|
627
|
+
*
|
|
628
|
+
* Plugin-agents never have allowed sites — they only participate in tickets.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} label
|
|
631
|
+
* @returns {Promise<string[]>}
|
|
632
|
+
*/
|
|
633
|
+
export async function getAgentAllowedSites(label) {
|
|
634
|
+
const registry = await loadAgentRegistry();
|
|
635
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
636
|
+
if (!agent) return [];
|
|
637
|
+
return agent.allowedSites || [];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Update allowed sites for an agent certificate.
|
|
642
|
+
*
|
|
643
|
+
* @param {string} label
|
|
644
|
+
* @param {string[]} allowedSites
|
|
645
|
+
* @returns {Promise<{ ok: true, label: string, allowedSites: string[] }>}
|
|
646
|
+
*/
|
|
647
|
+
export async function updateAgentAllowedSites(label, allowedSites) {
|
|
648
|
+
return withRegistryLock(async () => {
|
|
649
|
+
const registry = await loadAgentRegistry();
|
|
650
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
651
|
+
if (!agent) {
|
|
652
|
+
throw Object.assign(new Error(`Agent certificate "${label}" not found`), { statusCode: 404 });
|
|
653
|
+
}
|
|
654
|
+
agent.allowedSites = allowedSites || [];
|
|
655
|
+
await saveAgentRegistry(registry);
|
|
656
|
+
return { ok: true, label, allowedSites: agent.allowedSites };
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Revoke an agent certificate by label.
|
|
662
|
+
*
|
|
663
|
+
* Adds the serial to the revocation list, marks it revoked in the registry,
|
|
664
|
+
* and removes the agent's key/cert/p12 files.
|
|
665
|
+
*
|
|
666
|
+
* @param {string} label
|
|
667
|
+
* @param {import('pino').Logger} logger
|
|
668
|
+
* @returns {Promise<{ ok: true, label: string }>}
|
|
669
|
+
*/
|
|
670
|
+
export async function revokeAgentCert(label, logger) {
|
|
671
|
+
return withRegistryLock(async () => {
|
|
672
|
+
const registry = await loadAgentRegistry();
|
|
673
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
674
|
+
|
|
675
|
+
if (!agent) {
|
|
676
|
+
throw Object.assign(new Error(`Agent certificate "${label}" not found`), { statusCode: 404 });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 1. Add serial to revocation list
|
|
680
|
+
logger.info({ label, serial: agent.serial }, 'Revoking agent certificate');
|
|
681
|
+
await addToRevocationList(agent.serial, `agent:${label}`);
|
|
682
|
+
|
|
683
|
+
// 2. Mark revoked in registry
|
|
684
|
+
agent.revoked = true;
|
|
685
|
+
agent.revokedAt = new Date().toISOString();
|
|
686
|
+
|
|
687
|
+
// 3. Cascade revocation to plugin-agents delegated by this agent
|
|
688
|
+
const pluginAgents = registry.agents.filter((a) => !a.revoked && a.delegatedBy === label);
|
|
689
|
+
|
|
690
|
+
for (const pa of pluginAgents) {
|
|
691
|
+
logger.info(
|
|
692
|
+
{ label: pa.label, serial: pa.serial, delegatedBy: label },
|
|
693
|
+
'Cascade-revoking plugin-agent certificate',
|
|
694
|
+
);
|
|
695
|
+
await addToRevocationList(pa.serial, `agent:${pa.label}`);
|
|
696
|
+
pa.revoked = true;
|
|
697
|
+
pa.revokedAt = new Date().toISOString();
|
|
698
|
+
|
|
699
|
+
// Remove plugin-agent's key/cert/p12 files
|
|
700
|
+
await execa('rm', ['-rf', `${AGENTS_DIR}/${pa.label}/`]).catch((err) => {
|
|
701
|
+
logger.warn({ err, label: pa.label }, 'Failed to remove plugin-agent certificate files');
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (pluginAgents.length > 0) {
|
|
706
|
+
logger.info(
|
|
707
|
+
{ label, cascadeCount: pluginAgents.length },
|
|
708
|
+
'Cascade-revoked plugin-agent certificates',
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 4. Save registry atomically (includes both the agent and cascade-revoked plugin-agents)
|
|
713
|
+
await saveAgentRegistry(registry);
|
|
714
|
+
|
|
715
|
+
// 5. Remove agent's key/cert/p12 files (lamaste owns the agents directory)
|
|
716
|
+
await execa('rm', ['-rf', `${AGENTS_DIR}/${label}/`]).catch((err) => {
|
|
717
|
+
logger.warn({ err, label }, 'Failed to remove agent certificate files');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// 6. Remove chisel tunnel-server credential. Plugin-agents never had one.
|
|
721
|
+
if (!label.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
|
|
722
|
+
try {
|
|
723
|
+
await removeChiselCredential(label, logger);
|
|
724
|
+
} catch (chErr) {
|
|
725
|
+
logger.warn(
|
|
726
|
+
{ err: chErr, label },
|
|
727
|
+
'Failed to remove chisel credential during revocation — chisel-users file may need manual cleanup',
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Cascade-revoked plugin-agents have no chisel credentials to clean up.
|
|
733
|
+
|
|
734
|
+
// 7. Detach the agent from its tunnels, so a later enrollment that reuses
|
|
735
|
+
// the label inherits nothing. Runs after the credential removal, whose
|
|
736
|
+
// chisel restart already ended the agent's sessions.
|
|
737
|
+
if (!label.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
|
|
738
|
+
try {
|
|
739
|
+
await releaseAgentTunnels({
|
|
740
|
+
agentLabel: label,
|
|
741
|
+
nginx: tunnelNginxDeps,
|
|
742
|
+
chisel: tunnelChiselDeps,
|
|
743
|
+
state: tunnelStateDeps,
|
|
744
|
+
logger,
|
|
745
|
+
});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
logger.error(
|
|
748
|
+
{ err, label },
|
|
749
|
+
'Failed to release the tunnels of a revoked agent — assign or delete them by hand',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return { ok: true, label };
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Rotate an agent's chisel tunnel-server credential.
|
|
760
|
+
*
|
|
761
|
+
* Use this when a credential is suspected compromised or when an agent
|
|
762
|
+
* has lost its local copy. The new credential is returned so an operator
|
|
763
|
+
* can hand it to the agent out-of-band, but in normal operation the agent
|
|
764
|
+
* should re-fetch via `lamaste-agent chisel refresh-credential`.
|
|
765
|
+
*
|
|
766
|
+
* Verifies the agent exists and is not revoked first.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} label
|
|
769
|
+
* @param {import('pino').Logger} logger
|
|
770
|
+
* @returns {Promise<{ ok: true, label: string, user: string, password: string, createdAt: string, restartOk: boolean, restartError?: string }>}
|
|
771
|
+
*/
|
|
772
|
+
export async function rotateAgentChiselCredential(label, logger) {
|
|
773
|
+
const registry = await loadAgentRegistry();
|
|
774
|
+
const agent = registry.agents.find((a) => a.label === label && !a.revoked);
|
|
775
|
+
if (!agent) {
|
|
776
|
+
throw Object.assign(new Error(`Agent certificate "${label}" not found`), {
|
|
777
|
+
statusCode: 404,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (label.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
|
|
781
|
+
throw Object.assign(new Error('Plugin-agents do not use chisel tunnel credentials'), {
|
|
782
|
+
statusCode: 400,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const result = await rotateChiselCredentialLib(label, logger);
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
label,
|
|
789
|
+
user: result.user,
|
|
790
|
+
password: result.password,
|
|
791
|
+
createdAt: result.createdAt,
|
|
792
|
+
restartOk: result.restartOk,
|
|
793
|
+
...(result.restartOk ? {} : { restartError: result.restartError }),
|
|
794
|
+
};
|
|
795
|
+
}
|