@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,911 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { PLUGIN_CAPABILITY_REGEX } from '@lamalibre/lamaste';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { listCerts, renewCert, readLetsEncryptExpiry } from '../../lib/certbot.js';
|
|
5
|
+
import {
|
|
6
|
+
getMtlsCerts,
|
|
7
|
+
rotateClientCert,
|
|
8
|
+
getP12Path,
|
|
9
|
+
generateAgentCert,
|
|
10
|
+
listAgentCerts,
|
|
11
|
+
revokeAgentCert,
|
|
12
|
+
getAgentP12Path,
|
|
13
|
+
updateAgentCapabilities,
|
|
14
|
+
updateAgentAllowedSites,
|
|
15
|
+
getValidCapabilities,
|
|
16
|
+
fingerprintCertPem,
|
|
17
|
+
} from '../../lib/mtls.js';
|
|
18
|
+
import {
|
|
19
|
+
createEnrollmentToken,
|
|
20
|
+
createDelegatedEnrollmentToken,
|
|
21
|
+
revokeEnrollmentToken,
|
|
22
|
+
} from '../../lib/enrollment.js';
|
|
23
|
+
import { signAdminCSR, rotateAgentCSR } from '../../lib/csr-signing.js';
|
|
24
|
+
import { getConfig, updateConfig } from '../../lib/config.js';
|
|
25
|
+
import { addToRevocationList } from '../../lib/revocation.js';
|
|
26
|
+
import { agentOwnsInstanceForScope } from '../../lib/tickets.js';
|
|
27
|
+
import * as nginx from '../../lib/nginx.js';
|
|
28
|
+
|
|
29
|
+
const DomainParamSchema = z.object({
|
|
30
|
+
domain: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(
|
|
33
|
+
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
|
|
34
|
+
'Invalid domain name',
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const AgentGenerateBodySchema = z.object({
|
|
39
|
+
label: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(50)
|
|
43
|
+
.regex(/^[a-z0-9-]+$/, 'Label must contain only lowercase letters, numbers, and hyphens'),
|
|
44
|
+
capabilities: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.optional()
|
|
47
|
+
.default(['tunnels:read'])
|
|
48
|
+
.refine((caps) => caps.includes('tunnels:read'), {
|
|
49
|
+
message: 'tunnels:read is mandatory and cannot be removed',
|
|
50
|
+
})
|
|
51
|
+
.superRefine((caps, ctx) => {
|
|
52
|
+
const validCaps = getValidCapabilities();
|
|
53
|
+
for (const c of caps) {
|
|
54
|
+
if (!validCaps.includes(c)) {
|
|
55
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid capability: ${c}` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
allowedSites: z
|
|
60
|
+
.array(
|
|
61
|
+
z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(100)
|
|
65
|
+
.regex(
|
|
66
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
|
67
|
+
'Site name must contain only lowercase letters, numbers, and hyphens',
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
.optional()
|
|
71
|
+
.default([]),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const UpdateCapabilitiesSchema = z.object({
|
|
75
|
+
capabilities: z.array(z.string()).superRefine((caps, ctx) => {
|
|
76
|
+
const validCaps = getValidCapabilities();
|
|
77
|
+
for (const c of caps) {
|
|
78
|
+
if (!validCaps.includes(c)) {
|
|
79
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid capability: ${c}` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const UpdateAllowedSitesSchema = z.object({
|
|
86
|
+
allowedSites: z.array(
|
|
87
|
+
z
|
|
88
|
+
.string()
|
|
89
|
+
.min(1)
|
|
90
|
+
.max(100)
|
|
91
|
+
.regex(
|
|
92
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
|
|
93
|
+
'Site name must contain only lowercase letters, numbers, and hyphens',
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const AdminUpgradeSchema = z.object({
|
|
99
|
+
csr: z
|
|
100
|
+
.string()
|
|
101
|
+
.min(1, 'CSR is required')
|
|
102
|
+
.refine((v) => v.includes('BEGIN CERTIFICATE REQUEST'), {
|
|
103
|
+
message: 'CSR must be PEM-encoded',
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const AgentUpgradeSchema = z.object({
|
|
108
|
+
csr: z
|
|
109
|
+
.string()
|
|
110
|
+
.min(1, 'CSR is required')
|
|
111
|
+
.refine((v) => v.includes('BEGIN CERTIFICATE REQUEST'), {
|
|
112
|
+
message: 'CSR must be PEM-encoded',
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const AgentLabelParamSchema = z.object({
|
|
117
|
+
label: z
|
|
118
|
+
.string()
|
|
119
|
+
.min(1)
|
|
120
|
+
.max(150)
|
|
121
|
+
.regex(/^[a-z0-9][a-z0-9:-]*$/, 'Invalid agent label'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const DelegatedEnrollBodySchema = z.object({
|
|
125
|
+
pluginAgentLabel: z
|
|
126
|
+
.string()
|
|
127
|
+
.min(1)
|
|
128
|
+
.max(50)
|
|
129
|
+
.regex(
|
|
130
|
+
/^[a-z0-9][a-z0-9-]*$/,
|
|
131
|
+
'Label must start with a letter or number and contain only lowercase letters, numbers, and hyphens',
|
|
132
|
+
),
|
|
133
|
+
scope: z
|
|
134
|
+
.string()
|
|
135
|
+
.min(1)
|
|
136
|
+
.max(100)
|
|
137
|
+
// Ticket sub-scopes are namespaced like every plugin capability
|
|
138
|
+
// (`plugin:<name>:<action>`); ticket instances are registered under them.
|
|
139
|
+
.regex(
|
|
140
|
+
PLUGIN_CAPABILITY_REGEX,
|
|
141
|
+
'Scope must follow plugin:<name>:<action> format (e.g., plugin:sync:connect)',
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive a stable audit identifier for the calling principal.
|
|
147
|
+
*
|
|
148
|
+
* Returns the certificate CN form (`admin`, `agent:<label>`, `plugin-agent:...`)
|
|
149
|
+
* so a journalctl reader can correlate cert lifecycle events with the
|
|
150
|
+
* principal that initiated them. Returns `'unknown'` when middleware did not
|
|
151
|
+
* populate cert metadata (e.g. dev mode shortcut on /api/health).
|
|
152
|
+
*/
|
|
153
|
+
function actorFromRequest(request) {
|
|
154
|
+
if (request.certRole === 'admin') return 'admin';
|
|
155
|
+
if (request.certRole === 'agent' && request.certLabel) return `agent:${request.certLabel}`;
|
|
156
|
+
if (request.certRole === 'plugin-agent' && request.certLabel) return request.certLabel;
|
|
157
|
+
return 'unknown';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse certbot listing results into the unified cert response format.
|
|
162
|
+
*/
|
|
163
|
+
function formatLetsEncryptCerts(certbotCerts) {
|
|
164
|
+
return certbotCerts.map((cert) => ({
|
|
165
|
+
type: 'letsencrypt',
|
|
166
|
+
domain: cert.name,
|
|
167
|
+
expiresAt: cert.expiryDate,
|
|
168
|
+
daysUntilExpiry: cert.daysRemaining,
|
|
169
|
+
path: cert.certPath,
|
|
170
|
+
expiringSoon: cert.daysRemaining <= 30,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check the certbot auto-renewal timer status.
|
|
176
|
+
* Some systems use certbot.timer, others use certbot-renew.timer.
|
|
177
|
+
*/
|
|
178
|
+
async function getAutoRenewStatus() {
|
|
179
|
+
const timerNames = ['certbot.timer', 'certbot-renew.timer'];
|
|
180
|
+
|
|
181
|
+
for (const timerName of timerNames) {
|
|
182
|
+
try {
|
|
183
|
+
const { stdout: activeStatus } = await execa('systemctl', ['is-active', timerName]);
|
|
184
|
+
if (activeStatus.trim() !== 'active') continue;
|
|
185
|
+
|
|
186
|
+
let nextRun = null;
|
|
187
|
+
let lastRun = null;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const { stdout: nextElapse } = await execa('systemctl', [
|
|
191
|
+
'show',
|
|
192
|
+
timerName,
|
|
193
|
+
'--property=NextElapseUSecRealtime',
|
|
194
|
+
]);
|
|
195
|
+
const nextMatch = nextElapse.match(/NextElapseUSecRealtime=(.+)/);
|
|
196
|
+
if (nextMatch && nextMatch[1] && nextMatch[1] !== 'n/a') {
|
|
197
|
+
const parsed = new Date(nextMatch[1]);
|
|
198
|
+
if (!isNaN(parsed.getTime())) nextRun = parsed.toISOString();
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Ignore — next run time unavailable
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const { stdout: lastTrigger } = await execa('systemctl', [
|
|
206
|
+
'show',
|
|
207
|
+
timerName,
|
|
208
|
+
'--property=LastTriggerUSecRealtime',
|
|
209
|
+
]);
|
|
210
|
+
const lastMatch = lastTrigger.match(/LastTriggerUSecRealtime=(.+)/);
|
|
211
|
+
if (lastMatch && lastMatch[1] && lastMatch[1] !== 'n/a') {
|
|
212
|
+
const parsed = new Date(lastMatch[1]);
|
|
213
|
+
if (!isNaN(parsed.getTime())) lastRun = parsed.toISOString();
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// Ignore — last run time unavailable
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { active: true, nextRun, lastRun };
|
|
220
|
+
} catch {
|
|
221
|
+
// Timer doesn't exist or is inactive — try next name
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { active: false, nextRun: null, lastRun: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default async function certsRoutes(fastify, _opts) {
|
|
230
|
+
// ------------------------------------------------------------------
|
|
231
|
+
// GET /certs — list all certificates
|
|
232
|
+
// ------------------------------------------------------------------
|
|
233
|
+
fastify.get(
|
|
234
|
+
'/certs',
|
|
235
|
+
{
|
|
236
|
+
preHandler: fastify.requireRole(['admin']),
|
|
237
|
+
},
|
|
238
|
+
async (request, _reply) => {
|
|
239
|
+
const certs = [];
|
|
240
|
+
|
|
241
|
+
// Source 1: Let's Encrypt certificates
|
|
242
|
+
try {
|
|
243
|
+
const leCerts = await listCerts();
|
|
244
|
+
certs.push(...formatLetsEncryptCerts(leCerts));
|
|
245
|
+
} catch (err) {
|
|
246
|
+
request.log.error({ err }, "Failed to list Let's Encrypt certificates");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Source 2: mTLS certificates
|
|
250
|
+
try {
|
|
251
|
+
const mtlsCerts = await getMtlsCerts();
|
|
252
|
+
certs.push(...mtlsCerts);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
request.log.error({ err }, 'Failed to list mTLS certificates');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Sort by daysUntilExpiry ascending (soonest expiry first)
|
|
258
|
+
certs.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
|
|
259
|
+
|
|
260
|
+
return { certs };
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------------
|
|
265
|
+
// GET /certs/auto-renew-status — certbot timer status
|
|
266
|
+
// Must be registered BEFORE :domain routes to avoid param conflicts
|
|
267
|
+
// ------------------------------------------------------------------
|
|
268
|
+
fastify.get(
|
|
269
|
+
'/certs/auto-renew-status',
|
|
270
|
+
{
|
|
271
|
+
preHandler: fastify.requireRole(['admin']),
|
|
272
|
+
},
|
|
273
|
+
async (request, _reply) => {
|
|
274
|
+
try {
|
|
275
|
+
return await getAutoRenewStatus();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
request.log.error({ err }, 'Failed to get auto-renew status');
|
|
278
|
+
return { active: false, nextRun: null, lastRun: null };
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ------------------------------------------------------------------
|
|
284
|
+
// POST /certs/mtls/rotate — rotate mTLS client certificate
|
|
285
|
+
// Must be registered BEFORE :domain routes
|
|
286
|
+
// ------------------------------------------------------------------
|
|
287
|
+
fastify.post(
|
|
288
|
+
'/certs/mtls/rotate',
|
|
289
|
+
{
|
|
290
|
+
preHandler: fastify.requireRole(['admin']),
|
|
291
|
+
},
|
|
292
|
+
async (request, reply) => {
|
|
293
|
+
const actor = actorFromRequest(request);
|
|
294
|
+
// Block P12 rotation when admin uses hardware-bound auth
|
|
295
|
+
const config = getConfig();
|
|
296
|
+
if (config.adminAuthMode === 'hardware-bound') {
|
|
297
|
+
request.log.info(
|
|
298
|
+
{ admin: actor, rotatedFor: 'admin', outcome: 'refused', reason: 'hardware-bound' },
|
|
299
|
+
'mTLS certificate rotation refused',
|
|
300
|
+
);
|
|
301
|
+
return reply.code(410).send({
|
|
302
|
+
error:
|
|
303
|
+
'P12 certificate rotation is disabled. Admin uses hardware-bound authentication. Use lamaste-reset-admin on the server to revert.',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const result = await rotateClientCert(request.log);
|
|
309
|
+
|
|
310
|
+
// Reload nginx to pick up the new client cert
|
|
311
|
+
const testResult = await nginx.testConfig();
|
|
312
|
+
if (testResult.valid) {
|
|
313
|
+
try {
|
|
314
|
+
await nginx.reload();
|
|
315
|
+
} catch (reloadErr) {
|
|
316
|
+
request.log.error({ err: reloadErr }, 'nginx reload failed after mTLS rotation');
|
|
317
|
+
result.warning +=
|
|
318
|
+
' Note: nginx reload failed — you may need to restart nginx manually.';
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
request.log.error(
|
|
322
|
+
{ error: testResult.error },
|
|
323
|
+
'nginx config test failed after mTLS rotation',
|
|
324
|
+
);
|
|
325
|
+
result.warning += ' Note: nginx config test failed — you may need to fix nginx manually.';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
request.log.info(
|
|
329
|
+
{
|
|
330
|
+
admin: actor,
|
|
331
|
+
rotatedFor: 'admin',
|
|
332
|
+
newSerial: result?.serial ?? null,
|
|
333
|
+
fingerprintSha256: result?.certPem ? fingerprintCertPem(result.certPem) : null,
|
|
334
|
+
},
|
|
335
|
+
'mTLS certificate rotated',
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return result;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const statusCode = err.statusCode || 500;
|
|
341
|
+
request.log.error(
|
|
342
|
+
{ admin: actor, rotatedFor: 'admin', err, statusCode },
|
|
343
|
+
'mTLS certificate rotation failed',
|
|
344
|
+
);
|
|
345
|
+
return reply.code(statusCode).send({
|
|
346
|
+
error: err.message || 'mTLS rotation failed',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// ------------------------------------------------------------------
|
|
353
|
+
// GET /certs/mtls/download — download client.p12 file
|
|
354
|
+
// Must be registered BEFORE :domain routes
|
|
355
|
+
// ------------------------------------------------------------------
|
|
356
|
+
fastify.get(
|
|
357
|
+
'/certs/mtls/download',
|
|
358
|
+
{
|
|
359
|
+
preHandler: fastify.requireRole(['admin']),
|
|
360
|
+
// Strict tier — exports the admin client P12 private key. Capped at
|
|
361
|
+
// 5/min to limit the surface for an attacker who briefly gains an
|
|
362
|
+
// admin session and tries to exfiltrate the bundle.
|
|
363
|
+
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
|
|
364
|
+
},
|
|
365
|
+
async (request, reply) => {
|
|
366
|
+
// Block P12 download when admin uses hardware-bound auth
|
|
367
|
+
const config = getConfig();
|
|
368
|
+
if (config.adminAuthMode === 'hardware-bound') {
|
|
369
|
+
return reply.code(410).send({
|
|
370
|
+
error:
|
|
371
|
+
'P12 certificate download is disabled. Admin uses hardware-bound authentication. Use lamaste-reset-admin on the server to revert.',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const p12Path = getP12Path();
|
|
376
|
+
const { readFile } = await import('node:fs/promises');
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const data = await readFile(p12Path);
|
|
380
|
+
|
|
381
|
+
return reply
|
|
382
|
+
.header('Content-Type', 'application/x-pkcs12')
|
|
383
|
+
.header('Content-Disposition', 'attachment; filename="client.p12"')
|
|
384
|
+
.send(data);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (err.code === 'ENOENT' || err.code === 'EACCES') {
|
|
387
|
+
return reply.code(404).send({ error: 'No client certificate found' });
|
|
388
|
+
}
|
|
389
|
+
request.log.error({ err }, 'Failed to read client.p12');
|
|
390
|
+
return reply.code(500).send({ error: 'Failed to download certificate' });
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// ------------------------------------------------------------------
|
|
396
|
+
// POST /certs/admin/upgrade-to-hardware-bound — upgrade admin cert
|
|
397
|
+
// to hardware-bound (Keychain) authentication via CSR
|
|
398
|
+
// ------------------------------------------------------------------
|
|
399
|
+
fastify.post(
|
|
400
|
+
'/certs/admin/upgrade-to-hardware-bound',
|
|
401
|
+
{
|
|
402
|
+
preHandler: fastify.requireRole(['admin']),
|
|
403
|
+
},
|
|
404
|
+
async (request, reply) => {
|
|
405
|
+
const body = AdminUpgradeSchema.parse(request.body);
|
|
406
|
+
const actor = actorFromRequest(request);
|
|
407
|
+
|
|
408
|
+
const config = getConfig();
|
|
409
|
+
if (config.adminAuthMode === 'hardware-bound') {
|
|
410
|
+
return reply.code(409).send({
|
|
411
|
+
error: 'Admin is already using hardware-bound authentication',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const result = await signAdminCSR(body.csr, request.log);
|
|
417
|
+
|
|
418
|
+
// Revoke old admin cert
|
|
419
|
+
if (result.oldSerial) {
|
|
420
|
+
request.log.info({ serial: result.oldSerial }, 'Revoking old admin certificate');
|
|
421
|
+
await addToRevocationList(result.oldSerial, 'admin (upgraded to hardware-bound)');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Set adminAuthMode to hardware-bound
|
|
425
|
+
await updateConfig({ adminAuthMode: 'hardware-bound' });
|
|
426
|
+
|
|
427
|
+
// Reload nginx to pick up revocation changes
|
|
428
|
+
const testResult = await nginx.testConfig();
|
|
429
|
+
if (testResult.valid) {
|
|
430
|
+
try {
|
|
431
|
+
await nginx.reload();
|
|
432
|
+
} catch (reloadErr) {
|
|
433
|
+
request.log.error({ err: reloadErr }, 'nginx reload failed after admin upgrade');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
request.log.info(
|
|
438
|
+
{
|
|
439
|
+
admin: actor,
|
|
440
|
+
rotatedFor: 'admin',
|
|
441
|
+
mode: 'hardware-bound',
|
|
442
|
+
oldSerial: result.oldSerial ?? null,
|
|
443
|
+
newSerial: result.serial,
|
|
444
|
+
fingerprintSha256: result.certPem ? fingerprintCertPem(result.certPem) : null,
|
|
445
|
+
},
|
|
446
|
+
'mTLS certificate rotated (admin hardware-bound upgrade)',
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
cert: result.certPem,
|
|
452
|
+
caCert: result.caCertPem,
|
|
453
|
+
serial: result.serial,
|
|
454
|
+
expiresAt: result.expiresAt,
|
|
455
|
+
};
|
|
456
|
+
} catch (err) {
|
|
457
|
+
const statusCode = err.statusCode || 500;
|
|
458
|
+
request.log.error(
|
|
459
|
+
{ admin: actor, rotatedFor: 'admin', mode: 'hardware-bound', err, statusCode },
|
|
460
|
+
'mTLS certificate rotation failed (admin hardware-bound upgrade)',
|
|
461
|
+
);
|
|
462
|
+
return reply.code(statusCode).send({
|
|
463
|
+
error: err.message || 'Admin upgrade failed',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// ------------------------------------------------------------------
|
|
470
|
+
// GET /certs/admin/auth-mode — get current admin auth mode
|
|
471
|
+
// ------------------------------------------------------------------
|
|
472
|
+
fastify.get(
|
|
473
|
+
'/certs/admin/auth-mode',
|
|
474
|
+
{
|
|
475
|
+
preHandler: fastify.requireRole(['admin']),
|
|
476
|
+
},
|
|
477
|
+
async (_request, _reply) => {
|
|
478
|
+
const config = getConfig();
|
|
479
|
+
return { adminAuthMode: config.adminAuthMode || 'p12' };
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// ------------------------------------------------------------------
|
|
484
|
+
// POST /certs/agent/upgrade-cert — upgrade agent cert to hardware-bound
|
|
485
|
+
// via CSR. Agent-role only — agents can only rotate their own cert.
|
|
486
|
+
// ------------------------------------------------------------------
|
|
487
|
+
fastify.post(
|
|
488
|
+
'/certs/agent/upgrade-cert',
|
|
489
|
+
{
|
|
490
|
+
preHandler: fastify.requireRole(['agent']),
|
|
491
|
+
},
|
|
492
|
+
async (request, reply) => {
|
|
493
|
+
const body = AgentUpgradeSchema.parse(request.body);
|
|
494
|
+
const label = request.certLabel;
|
|
495
|
+
const actor = actorFromRequest(request);
|
|
496
|
+
|
|
497
|
+
if (!label) {
|
|
498
|
+
return reply.code(403).send({ error: 'Agent label not found in certificate' });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const result = await rotateAgentCSR(body.csr, label, request.log);
|
|
503
|
+
request.log.info(
|
|
504
|
+
{
|
|
505
|
+
admin: actor,
|
|
506
|
+
rotatedFor: `agent:${label}`,
|
|
507
|
+
mode: 'hardware-bound',
|
|
508
|
+
newSerial: result.serial,
|
|
509
|
+
fingerprintSha256: result.certPem ? fingerprintCertPem(result.certPem) : null,
|
|
510
|
+
},
|
|
511
|
+
'mTLS certificate rotated (agent hardware-bound upgrade)',
|
|
512
|
+
);
|
|
513
|
+
return {
|
|
514
|
+
ok: true,
|
|
515
|
+
cert: result.certPem,
|
|
516
|
+
caCert: result.caCertPem,
|
|
517
|
+
serial: result.serial,
|
|
518
|
+
expiresAt: result.expiresAt,
|
|
519
|
+
};
|
|
520
|
+
} catch (err) {
|
|
521
|
+
const statusCode = err.statusCode || 500;
|
|
522
|
+
request.log.error(
|
|
523
|
+
{ admin: actor, rotatedFor: `agent:${label}`, mode: 'hardware-bound', err, statusCode },
|
|
524
|
+
'mTLS certificate rotation failed (agent hardware-bound upgrade)',
|
|
525
|
+
);
|
|
526
|
+
return reply.code(statusCode).send({
|
|
527
|
+
error: err.message || 'Agent certificate upgrade failed',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// ------------------------------------------------------------------
|
|
534
|
+
// POST /certs/agent — generate a new agent certificate
|
|
535
|
+
// ------------------------------------------------------------------
|
|
536
|
+
fastify.post(
|
|
537
|
+
'/certs/agent',
|
|
538
|
+
{
|
|
539
|
+
preHandler: fastify.requireRole(['admin']),
|
|
540
|
+
},
|
|
541
|
+
async (request, reply) => {
|
|
542
|
+
const body = AgentGenerateBodySchema.parse(request.body);
|
|
543
|
+
const actor = actorFromRequest(request);
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const result = await generateAgentCert(
|
|
547
|
+
body.label,
|
|
548
|
+
request.log,
|
|
549
|
+
body.capabilities,
|
|
550
|
+
body.allowedSites,
|
|
551
|
+
);
|
|
552
|
+
request.log.info(
|
|
553
|
+
{
|
|
554
|
+
admin: actor,
|
|
555
|
+
issuedFor: `agent:${body.label}`,
|
|
556
|
+
mode: 'p12',
|
|
557
|
+
newSerial: result.serial,
|
|
558
|
+
capabilities: body.capabilities,
|
|
559
|
+
},
|
|
560
|
+
'mTLS certificate issued (agent)',
|
|
561
|
+
);
|
|
562
|
+
return { ok: true, ...result };
|
|
563
|
+
} catch (err) {
|
|
564
|
+
const statusCode = err.statusCode || 500;
|
|
565
|
+
request.log.error(
|
|
566
|
+
{ admin: actor, issuedFor: `agent:${body.label}`, mode: 'p12', err, statusCode },
|
|
567
|
+
'mTLS certificate issuance failed (agent)',
|
|
568
|
+
);
|
|
569
|
+
return reply.code(statusCode).send({
|
|
570
|
+
error: err.message || 'Agent certificate generation failed',
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// ------------------------------------------------------------------
|
|
577
|
+
// POST /certs/agent/enroll — generate a one-time enrollment token
|
|
578
|
+
// for hardware-bound agent certificate enrollment via CSR
|
|
579
|
+
// ------------------------------------------------------------------
|
|
580
|
+
fastify.post(
|
|
581
|
+
'/certs/agent/enroll',
|
|
582
|
+
{
|
|
583
|
+
preHandler: fastify.requireRole(['admin']),
|
|
584
|
+
},
|
|
585
|
+
async (request, reply) => {
|
|
586
|
+
const body = AgentGenerateBodySchema.parse(request.body);
|
|
587
|
+
const actor = actorFromRequest(request);
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
const result = await createEnrollmentToken(
|
|
591
|
+
body.label,
|
|
592
|
+
body.capabilities,
|
|
593
|
+
body.allowedSites,
|
|
594
|
+
request.log,
|
|
595
|
+
);
|
|
596
|
+
request.log.info(
|
|
597
|
+
{
|
|
598
|
+
admin: actor,
|
|
599
|
+
issuedFor: `agent:${body.label}`,
|
|
600
|
+
mode: 'enrollment-token',
|
|
601
|
+
capabilities: body.capabilities,
|
|
602
|
+
expiresAt: result.expiresAt ?? null,
|
|
603
|
+
},
|
|
604
|
+
'Enrollment token issued (agent hardware-bound)',
|
|
605
|
+
);
|
|
606
|
+
return { ok: true, ...result };
|
|
607
|
+
} catch (err) {
|
|
608
|
+
const statusCode = err.statusCode || 500;
|
|
609
|
+
request.log.error(
|
|
610
|
+
{
|
|
611
|
+
admin: actor,
|
|
612
|
+
issuedFor: `agent:${body.label}`,
|
|
613
|
+
mode: 'enrollment-token',
|
|
614
|
+
err,
|
|
615
|
+
statusCode,
|
|
616
|
+
},
|
|
617
|
+
'Enrollment token issuance failed (agent hardware-bound)',
|
|
618
|
+
);
|
|
619
|
+
return reply.code(statusCode).send({
|
|
620
|
+
error: err.message || 'Enrollment token generation failed',
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// ------------------------------------------------------------------
|
|
627
|
+
// DELETE /certs/agent/enroll/:label — revoke unused enrollment token
|
|
628
|
+
// Called by the desktop app when agent installation fails before
|
|
629
|
+
// enrollment, preventing the token from being used elsewhere.
|
|
630
|
+
// ------------------------------------------------------------------
|
|
631
|
+
fastify.delete(
|
|
632
|
+
'/certs/agent/enroll/:label',
|
|
633
|
+
{
|
|
634
|
+
preHandler: fastify.requireRole(['admin']),
|
|
635
|
+
},
|
|
636
|
+
async (request, _reply) => {
|
|
637
|
+
const params = AgentLabelParamSchema.parse(request.params);
|
|
638
|
+
const actor = actorFromRequest(request);
|
|
639
|
+
const result = await revokeEnrollmentToken(params.label, request.log);
|
|
640
|
+
request.log.info(
|
|
641
|
+
{ admin: actor, revokedFor: `agent:${params.label}`, mode: 'enrollment-token' },
|
|
642
|
+
'Enrollment token revoked',
|
|
643
|
+
);
|
|
644
|
+
return { ok: true, ...result };
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// ------------------------------------------------------------------
|
|
649
|
+
// POST /certs/agent/enroll-delegated — pre-announce a delegated enrollment
|
|
650
|
+
// token for a plugin agent. Requires agent role — only agents that own
|
|
651
|
+
// a ticket instance for the given scope can delegate enrollment.
|
|
652
|
+
// ------------------------------------------------------------------
|
|
653
|
+
fastify.post(
|
|
654
|
+
'/certs/agent/enroll-delegated',
|
|
655
|
+
{
|
|
656
|
+
preHandler: fastify.requireRole(['agent']),
|
|
657
|
+
},
|
|
658
|
+
async (request, reply) => {
|
|
659
|
+
// Plugin-agents cannot delegate enrollment — prevents recursive delegation chains
|
|
660
|
+
if (request.certRole === 'plugin-agent') {
|
|
661
|
+
return reply.code(403).send({ error: 'Plugin agents cannot delegate enrollment' });
|
|
662
|
+
}
|
|
663
|
+
// Only an agent can own a ticket instance. Refused before the body is
|
|
664
|
+
// validated, so the answer to a non-agent never depends on its input.
|
|
665
|
+
if (request.certRole !== 'agent') {
|
|
666
|
+
return reply.code(403).send({ error: 'Only agent certificates can delegate enrollment' });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const body = DelegatedEnrollBodySchema.parse(request.body);
|
|
670
|
+
const delegatingLabel = request.certLabel;
|
|
671
|
+
|
|
672
|
+
if (!delegatingLabel) {
|
|
673
|
+
return reply.code(403).send({ error: 'Agent label not found in certificate' });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Validate the calling agent owns a ticket instance for the given scope
|
|
677
|
+
const ownsInstance = await agentOwnsInstanceForScope(delegatingLabel, body.scope);
|
|
678
|
+
if (!ownsInstance) {
|
|
679
|
+
return reply.code(403).send({
|
|
680
|
+
error: 'Insufficient delegation authority',
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const actor = actorFromRequest(request);
|
|
685
|
+
try {
|
|
686
|
+
const result = await createDelegatedEnrollmentToken(
|
|
687
|
+
delegatingLabel,
|
|
688
|
+
body.scope,
|
|
689
|
+
body.pluginAgentLabel,
|
|
690
|
+
request.log,
|
|
691
|
+
);
|
|
692
|
+
request.log.info(
|
|
693
|
+
{
|
|
694
|
+
admin: actor,
|
|
695
|
+
issuedFor: result.pluginAgentLabel,
|
|
696
|
+
mode: 'enrollment-token-delegated',
|
|
697
|
+
scope: body.scope,
|
|
698
|
+
delegatedBy: delegatingLabel,
|
|
699
|
+
expiresAt: result.expiresAt ?? null,
|
|
700
|
+
},
|
|
701
|
+
'Enrollment token issued (delegated plugin-agent)',
|
|
702
|
+
);
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
enrollmentToken: result.token,
|
|
706
|
+
expiresAt: result.expiresAt,
|
|
707
|
+
pluginAgentLabel: result.pluginAgentLabel,
|
|
708
|
+
};
|
|
709
|
+
} catch (err) {
|
|
710
|
+
const statusCode = err.statusCode || 500;
|
|
711
|
+
request.log.error(
|
|
712
|
+
{
|
|
713
|
+
admin: actor,
|
|
714
|
+
mode: 'enrollment-token-delegated',
|
|
715
|
+
scope: body.scope,
|
|
716
|
+
delegatedBy: delegatingLabel,
|
|
717
|
+
requestedFor: body.pluginAgentLabel,
|
|
718
|
+
err,
|
|
719
|
+
statusCode,
|
|
720
|
+
},
|
|
721
|
+
'Enrollment token issuance failed (delegated plugin-agent)',
|
|
722
|
+
);
|
|
723
|
+
return reply.code(statusCode).send({
|
|
724
|
+
error: err.message || 'Delegated enrollment token generation failed',
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
// ------------------------------------------------------------------
|
|
731
|
+
// GET /certs/agent — list all agent certificates
|
|
732
|
+
// ------------------------------------------------------------------
|
|
733
|
+
fastify.get(
|
|
734
|
+
'/certs/agent',
|
|
735
|
+
{
|
|
736
|
+
preHandler: fastify.requireRole(['admin']),
|
|
737
|
+
},
|
|
738
|
+
async (_request, _reply) => {
|
|
739
|
+
const agents = await listAgentCerts();
|
|
740
|
+
return { agents };
|
|
741
|
+
},
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// ------------------------------------------------------------------
|
|
745
|
+
// GET /certs/agent/:label/download — download agent P12 file
|
|
746
|
+
// ------------------------------------------------------------------
|
|
747
|
+
fastify.get(
|
|
748
|
+
'/certs/agent/:label/download',
|
|
749
|
+
{
|
|
750
|
+
preHandler: fastify.requireRole(['admin']),
|
|
751
|
+
// Strict tier — exports an agent client P12 private key. Same
|
|
752
|
+
// ceiling as the admin P12 download.
|
|
753
|
+
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
|
|
754
|
+
},
|
|
755
|
+
async (request, reply) => {
|
|
756
|
+
const params = AgentLabelParamSchema.parse(request.params);
|
|
757
|
+
const p12Path = getAgentP12Path(params.label);
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
const { readFile: readFileRaw } = await import('node:fs/promises');
|
|
761
|
+
const fileBuffer = await readFileRaw(p12Path);
|
|
762
|
+
|
|
763
|
+
return reply
|
|
764
|
+
.header('Content-Type', 'application/x-pkcs12')
|
|
765
|
+
.header('Content-Disposition', `attachment; filename="${params.label}.p12"`)
|
|
766
|
+
.send(fileBuffer);
|
|
767
|
+
} catch (err) {
|
|
768
|
+
if (err.code === 'ENOENT') {
|
|
769
|
+
return reply.code(404).send({ error: `Agent certificate "${params.label}" not found` });
|
|
770
|
+
}
|
|
771
|
+
request.log.error({ err }, 'Failed to read agent client.p12');
|
|
772
|
+
return reply.code(500).send({ error: 'Failed to download agent certificate' });
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
// ------------------------------------------------------------------
|
|
778
|
+
// DELETE /certs/agent/:label — revoke an agent certificate
|
|
779
|
+
// ------------------------------------------------------------------
|
|
780
|
+
fastify.delete(
|
|
781
|
+
'/certs/agent/:label',
|
|
782
|
+
{
|
|
783
|
+
preHandler: fastify.requireRole(['admin']),
|
|
784
|
+
},
|
|
785
|
+
async (request, reply) => {
|
|
786
|
+
const params = AgentLabelParamSchema.parse(request.params);
|
|
787
|
+
const actor = actorFromRequest(request);
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const result = await revokeAgentCert(params.label, request.log);
|
|
791
|
+
request.log.info(
|
|
792
|
+
{ admin: actor, revokedFor: `agent:${params.label}` },
|
|
793
|
+
'mTLS certificate revoked (agent)',
|
|
794
|
+
);
|
|
795
|
+
return result;
|
|
796
|
+
} catch (err) {
|
|
797
|
+
const statusCode = err.statusCode || 500;
|
|
798
|
+
request.log.error(
|
|
799
|
+
{ admin: actor, revokedFor: `agent:${params.label}`, err, statusCode },
|
|
800
|
+
'mTLS certificate revocation failed (agent)',
|
|
801
|
+
);
|
|
802
|
+
return reply.code(statusCode).send({
|
|
803
|
+
error: err.message || 'Agent certificate revocation failed',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
// ------------------------------------------------------------------
|
|
810
|
+
// PATCH /certs/agent/:label/capabilities — update agent capabilities
|
|
811
|
+
// ------------------------------------------------------------------
|
|
812
|
+
fastify.patch(
|
|
813
|
+
'/certs/agent/:label/capabilities',
|
|
814
|
+
{
|
|
815
|
+
preHandler: fastify.requireRole(['admin']),
|
|
816
|
+
},
|
|
817
|
+
async (request, reply) => {
|
|
818
|
+
const params = AgentLabelParamSchema.parse(request.params);
|
|
819
|
+
const body = UpdateCapabilitiesSchema.parse(request.body);
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const result = await updateAgentCapabilities(params.label, body.capabilities);
|
|
823
|
+
return result;
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const statusCode = err.statusCode || 500;
|
|
826
|
+
return reply.code(statusCode).send({
|
|
827
|
+
error: err.message || 'Failed to update capabilities',
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
// ------------------------------------------------------------------
|
|
834
|
+
// PATCH /certs/agent/:label/allowed-sites — update agent allowed sites
|
|
835
|
+
// ------------------------------------------------------------------
|
|
836
|
+
fastify.patch(
|
|
837
|
+
'/certs/agent/:label/allowed-sites',
|
|
838
|
+
{
|
|
839
|
+
preHandler: fastify.requireRole(['admin']),
|
|
840
|
+
},
|
|
841
|
+
async (request, reply) => {
|
|
842
|
+
const params = AgentLabelParamSchema.parse(request.params);
|
|
843
|
+
const body = UpdateAllowedSitesSchema.parse(request.body);
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const result = await updateAgentAllowedSites(params.label, body.allowedSites);
|
|
847
|
+
return result;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
const statusCode = err.statusCode || 500;
|
|
850
|
+
return reply.code(statusCode).send({
|
|
851
|
+
error: err.message || 'Failed to update allowed sites',
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
// ------------------------------------------------------------------
|
|
858
|
+
// POST /certs/:domain/renew — force-renew a Let's Encrypt certificate
|
|
859
|
+
// ------------------------------------------------------------------
|
|
860
|
+
fastify.post(
|
|
861
|
+
'/certs/:domain/renew',
|
|
862
|
+
{
|
|
863
|
+
preHandler: fastify.requireRole(['admin']),
|
|
864
|
+
},
|
|
865
|
+
async (request, reply) => {
|
|
866
|
+
const params = DomainParamSchema.parse(request.params);
|
|
867
|
+
const { domain } = params;
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
await renewCert(domain, { forceRenewal: true });
|
|
871
|
+
} catch (err) {
|
|
872
|
+
const msg = err.message || '';
|
|
873
|
+
|
|
874
|
+
if (msg.includes('No certificate found') || msg.includes('not found')) {
|
|
875
|
+
return reply.code(404).send({ error: 'Certificate not found' });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
request.log.error({ err, domain }, 'Certificate renewal failed');
|
|
879
|
+
return reply.code(500).send({
|
|
880
|
+
error: 'Certificate renewal failed',
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Read the new expiry date
|
|
885
|
+
const expiry = await readLetsEncryptExpiry(domain);
|
|
886
|
+
const newExpiry = expiry?.expiresAt || null;
|
|
887
|
+
|
|
888
|
+
// Reload nginx
|
|
889
|
+
const testResult = await nginx.testConfig();
|
|
890
|
+
let warning;
|
|
891
|
+
if (testResult.valid) {
|
|
892
|
+
try {
|
|
893
|
+
await nginx.reload();
|
|
894
|
+
} catch (reloadErr) {
|
|
895
|
+
request.log.error({ err: reloadErr }, 'nginx reload failed after cert renewal');
|
|
896
|
+
warning = 'Certificate renewed but nginx reload failed';
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
request.log.error(
|
|
900
|
+
{ error: testResult.error },
|
|
901
|
+
'nginx config test failed after cert renewal',
|
|
902
|
+
);
|
|
903
|
+
warning = 'Certificate renewed but nginx config test failed';
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const result = { ok: true, domain, newExpiry };
|
|
907
|
+
if (warning) result.warning = warning;
|
|
908
|
+
return result;
|
|
909
|
+
},
|
|
910
|
+
);
|
|
911
|
+
}
|