@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.
Files changed (270) hide show
  1. package/LICENSE.md +118 -0
  2. package/README.md +114 -0
  3. package/bin/create-lamaste.js +15 -0
  4. package/package.json +54 -0
  5. package/scripts/bundle-vendor.js +110 -0
  6. package/scripts/lamaste-cert-info +62 -0
  7. package/scripts/lamaste-certbot +96 -0
  8. package/scripts/lamaste-priv +693 -0
  9. package/src/index.js +646 -0
  10. package/src/lib/accounts.js +86 -0
  11. package/src/lib/binaries.js +137 -0
  12. package/src/lib/cert-help-page.js +160 -0
  13. package/src/lib/env.js +132 -0
  14. package/src/lib/http-redirect.js +38 -0
  15. package/src/lib/ownership.js +231 -0
  16. package/src/lib/relay-services.js +103 -0
  17. package/src/lib/secrets.js +19 -0
  18. package/src/lib/service-config.js +311 -0
  19. package/src/lib/summary.js +131 -0
  20. package/src/lib/wrappers.js +63 -0
  21. package/src/tasks/gatekeeper.js +233 -0
  22. package/src/tasks/harden.js +293 -0
  23. package/src/tasks/mtls.js +193 -0
  24. package/src/tasks/nginx.js +265 -0
  25. package/src/tasks/node.js +110 -0
  26. package/src/tasks/panel.js +408 -0
  27. package/src/tasks/redeploy.js +438 -0
  28. package/vendor/docs/00-introduction/how-it-works.md +447 -0
  29. package/vendor/docs/00-introduction/quickstart.md +472 -0
  30. package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
  31. package/vendor/docs/01-concepts/authentication.md +464 -0
  32. package/vendor/docs/01-concepts/authorization.md +242 -0
  33. package/vendor/docs/01-concepts/certificates.md +452 -0
  34. package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
  35. package/vendor/docs/01-concepts/mtls.md +490 -0
  36. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
  37. package/vendor/docs/01-concepts/security-model.md +600 -0
  38. package/vendor/docs/01-concepts/tickets.md +414 -0
  39. package/vendor/docs/01-concepts/tunneling.md +367 -0
  40. package/vendor/docs/02-guides/agent-setup.md +194 -0
  41. package/vendor/docs/02-guides/certificate-management.md +341 -0
  42. package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
  43. package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
  44. package/vendor/docs/02-guides/disaster-recovery.md +464 -0
  45. package/vendor/docs/02-guides/first-tunnel.md +230 -0
  46. package/vendor/docs/02-guides/installation.md +393 -0
  47. package/vendor/docs/02-guides/managing-users.md +255 -0
  48. package/vendor/docs/02-guides/onboarding.md +244 -0
  49. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
  50. package/vendor/docs/02-guides/static-sites.md +364 -0
  51. package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
  52. package/vendor/docs/03-architecture/installer-flow.md +151 -0
  53. package/vendor/docs/03-architecture/installer.md +445 -0
  54. package/vendor/docs/03-architecture/management-flow.md +288 -0
  55. package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
  56. package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
  57. package/vendor/docs/03-architecture/overview.md +541 -0
  58. package/vendor/docs/03-architecture/panel-client.md +416 -0
  59. package/vendor/docs/03-architecture/panel-server.md +695 -0
  60. package/vendor/docs/03-architecture/state-management.md +529 -0
  61. package/vendor/docs/03-architecture/system-overview.md +167 -0
  62. package/vendor/docs/04-api-reference/certificates.md +1141 -0
  63. package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
  64. package/vendor/docs/04-api-reference/onboarding.md +473 -0
  65. package/vendor/docs/04-api-reference/overview.md +404 -0
  66. package/vendor/docs/04-api-reference/services.md +267 -0
  67. package/vendor/docs/04-api-reference/settings.md +343 -0
  68. package/vendor/docs/04-api-reference/sites.md +716 -0
  69. package/vendor/docs/04-api-reference/system.md +268 -0
  70. package/vendor/docs/04-api-reference/tickets.md +618 -0
  71. package/vendor/docs/04-api-reference/tunnels.md +417 -0
  72. package/vendor/docs/04-api-reference/users.md +333 -0
  73. package/vendor/docs/05-operations/backup-and-restore.md +342 -0
  74. package/vendor/docs/05-operations/monitoring.md +285 -0
  75. package/vendor/docs/05-operations/uninstalling.md +344 -0
  76. package/vendor/docs/05-operations/upgrades.md +270 -0
  77. package/vendor/docs/06-reference/config-files.md +1080 -0
  78. package/vendor/docs/06-reference/glossary.md +528 -0
  79. package/vendor/docs/06-reference/installer-flags.md +260 -0
  80. package/vendor/docs/06-reference/ports-and-services.md +244 -0
  81. package/vendor/docs/06-reference/troubleshooting.md +665 -0
  82. package/vendor/docs/_index.json +233 -0
  83. package/vendor/docs/gatekeeper-design.md +953 -0
  84. package/vendor/docs/package.json +15 -0
  85. package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
  86. package/vendor/gatekeeper/dist/boot.d.ts +1 -0
  87. package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
  88. package/vendor/gatekeeper/dist/boot.js +17 -0
  89. package/vendor/gatekeeper/dist/boot.js.map +1 -0
  90. package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
  91. package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
  92. package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
  93. package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
  94. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
  95. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
  96. package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
  97. package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
  98. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
  99. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
  100. package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
  101. package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
  102. package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
  103. package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
  104. package/vendor/gatekeeper/dist/cli/index.js +45 -0
  105. package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
  106. package/vendor/gatekeeper/dist/index.d.ts +9 -0
  107. package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
  108. package/vendor/gatekeeper/dist/index.js +13 -0
  109. package/vendor/gatekeeper/dist/index.js.map +1 -0
  110. package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
  111. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
  112. package/vendor/gatekeeper/dist/lib/authz.js +42 -0
  113. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
  114. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
  115. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
  116. package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
  117. package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
  118. package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
  119. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
  120. package/vendor/gatekeeper/dist/lib/constants.js +79 -0
  121. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
  122. package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
  123. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
  124. package/vendor/gatekeeper/dist/lib/grants.js +314 -0
  125. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
  126. package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
  127. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
  128. package/vendor/gatekeeper/dist/lib/groups.js +247 -0
  129. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
  130. package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
  131. package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
  132. package/vendor/gatekeeper/dist/lib/migration.js +79 -0
  133. package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
  134. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
  135. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
  136. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  137. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
  138. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
  139. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  140. package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
  141. package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
  142. package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
  143. package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
  144. package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
  145. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
  146. package/vendor/gatekeeper/dist/lib/templates.js +151 -0
  147. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
  148. package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
  149. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
  150. package/vendor/gatekeeper/dist/lib/types.js +2 -0
  151. package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
  152. package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
  153. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
  154. package/vendor/gatekeeper/dist/server/index.js +251 -0
  155. package/vendor/gatekeeper/dist/server/index.js.map +1 -0
  156. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
  157. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
  158. package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
  159. package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
  160. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
  161. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
  162. package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
  163. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
  164. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
  165. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
  166. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
  167. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
  168. package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
  169. package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
  170. package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
  171. package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
  172. package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
  173. package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
  174. package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
  175. package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
  176. package/vendor/gatekeeper/package.json +49 -0
  177. package/vendor/server/bin/lamaste-server.js +204 -0
  178. package/vendor/server/package.json +31 -0
  179. package/vendor/server/src/commands/certs.js +224 -0
  180. package/vendor/server/src/commands/chisel.js +132 -0
  181. package/vendor/server/src/commands/logs.js +49 -0
  182. package/vendor/server/src/commands/plugins.js +321 -0
  183. package/vendor/server/src/commands/reset-admin.js +478 -0
  184. package/vendor/server/src/commands/restart.js +94 -0
  185. package/vendor/server/src/commands/sites.js +257 -0
  186. package/vendor/server/src/commands/status.js +158 -0
  187. package/vendor/server/src/commands/tunnels.js +365 -0
  188. package/vendor/server/src/commands/uninstall.js +326 -0
  189. package/vendor/server/src/config.js +38 -0
  190. package/vendor/server/src/exec.js +26 -0
  191. package/vendor/server/src/logger.js +39 -0
  192. package/vendor/server/src/ndjson.js +56 -0
  193. package/vendor/server/src/panel-api.js +85 -0
  194. package/vendor/server/src/state.js +45 -0
  195. package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
  196. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
  197. package/vendor/server-ui/dist/index.html +13 -0
  198. package/vendor/serverd/package.json +44 -0
  199. package/vendor/serverd/src/boot.js +13 -0
  200. package/vendor/serverd/src/index.js +191 -0
  201. package/vendor/serverd/src/lib/app-error.js +14 -0
  202. package/vendor/serverd/src/lib/authelia.js +85 -0
  203. package/vendor/serverd/src/lib/certbot.js +74 -0
  204. package/vendor/serverd/src/lib/chisel-args.js +6 -0
  205. package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
  206. package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
  207. package/vendor/serverd/src/lib/chisel-users.js +109 -0
  208. package/vendor/serverd/src/lib/chisel.js +81 -0
  209. package/vendor/serverd/src/lib/config.js +190 -0
  210. package/vendor/serverd/src/lib/constants.js +8 -0
  211. package/vendor/serverd/src/lib/csr-signing.js +331 -0
  212. package/vendor/serverd/src/lib/enrollment.js +302 -0
  213. package/vendor/serverd/src/lib/files.js +19 -0
  214. package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
  215. package/vendor/serverd/src/lib/invite-page.js +281 -0
  216. package/vendor/serverd/src/lib/ip-utils.js +79 -0
  217. package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
  218. package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
  219. package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
  220. package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
  221. package/vendor/serverd/src/lib/mtls.js +795 -0
  222. package/vendor/serverd/src/lib/nginx.js +1090 -0
  223. package/vendor/serverd/src/lib/pki-paths.js +41 -0
  224. package/vendor/serverd/src/lib/pki-sign.js +103 -0
  225. package/vendor/serverd/src/lib/plugins.js +538 -0
  226. package/vendor/serverd/src/lib/push-install.js +390 -0
  227. package/vendor/serverd/src/lib/revocation.js +112 -0
  228. package/vendor/serverd/src/lib/services.js +131 -0
  229. package/vendor/serverd/src/lib/session.js +151 -0
  230. package/vendor/serverd/src/lib/state-db.js +130 -0
  231. package/vendor/serverd/src/lib/state.js +140 -0
  232. package/vendor/serverd/src/lib/storage.js +63 -0
  233. package/vendor/serverd/src/lib/system-stats.js +58 -0
  234. package/vendor/serverd/src/lib/tickets.js +1556 -0
  235. package/vendor/serverd/src/lib/totp.js +76 -0
  236. package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
  237. package/vendor/serverd/src/lib/user-access-session.js +142 -0
  238. package/vendor/serverd/src/lib/user-access.js +52 -0
  239. package/vendor/serverd/src/middleware/errors.js +63 -0
  240. package/vendor/serverd/src/middleware/mtls.js +120 -0
  241. package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
  242. package/vendor/serverd/src/middleware/role-guard.js +58 -0
  243. package/vendor/serverd/src/middleware/twofa-session.js +65 -0
  244. package/vendor/serverd/src/middleware/user-access-session.js +52 -0
  245. package/vendor/serverd/src/routes/enrollment.js +120 -0
  246. package/vendor/serverd/src/routes/health.js +22 -0
  247. package/vendor/serverd/src/routes/invite.js +163 -0
  248. package/vendor/serverd/src/routes/management/agents.js +182 -0
  249. package/vendor/serverd/src/routes/management/certs.js +911 -0
  250. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
  251. package/vendor/serverd/src/routes/management/identity.js +107 -0
  252. package/vendor/serverd/src/routes/management/invitations.js +166 -0
  253. package/vendor/serverd/src/routes/management/logs.js +218 -0
  254. package/vendor/serverd/src/routes/management/plugins.js +560 -0
  255. package/vendor/serverd/src/routes/management/services.js +63 -0
  256. package/vendor/serverd/src/routes/management/settings.js +325 -0
  257. package/vendor/serverd/src/routes/management/sites.js +522 -0
  258. package/vendor/serverd/src/routes/management/storage.js +192 -0
  259. package/vendor/serverd/src/routes/management/system.js +201 -0
  260. package/vendor/serverd/src/routes/management/tickets.js +457 -0
  261. package/vendor/serverd/src/routes/management/tunnels.js +709 -0
  262. package/vendor/serverd/src/routes/management/users.js +377 -0
  263. package/vendor/serverd/src/routes/management.js +36 -0
  264. package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
  265. package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
  266. package/vendor/serverd/src/routes/onboarding/index.js +18 -0
  267. package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
  268. package/vendor/serverd/src/routes/onboarding/status.js +12 -0
  269. package/vendor/serverd/src/routes/plugin-router.js +79 -0
  270. 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
+ }