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