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