@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,41 @@
1
+ /**
2
+ * PKI path derivation for the Lamaste server daemon.
3
+ *
4
+ * Exposes the canonical CA cert/key locations consumed by other daemon
5
+ * modules (mtls, csr-signing, enrollment, revocation, plugins) and shared
6
+ * with plugins through the plugin host's `resolvePluginOptions` callback.
7
+ *
8
+ * The directory is derived from `LAMALIBRE_LAMASTE_PKI_DIR` with the same
9
+ * `/etc/lamalibre/lamaste/pki` default used throughout the daemon. Keeping the
10
+ * default in one exported helper gives us a single place to point at when
11
+ * the other consumers are migrated onto it.
12
+ */
13
+ import path from 'node:path';
14
+
15
+ const DEFAULT_PKI_DIR = '/etc/lamalibre/lamaste/pki';
16
+
17
+ /**
18
+ * Return the PKI directory.
19
+ *
20
+ * @returns {string}
21
+ */
22
+ export function getPkiDir() {
23
+ return process.env.LAMALIBRE_LAMASTE_PKI_DIR || DEFAULT_PKI_DIR;
24
+ }
25
+
26
+ /**
27
+ * Return the canonical CA cert and key paths.
28
+ *
29
+ * The files themselves are not read here — callers that need to verify
30
+ * existence or parse the certificate must do so explicitly. This helper
31
+ * only centralizes the path derivation.
32
+ *
33
+ * @returns {{ certPath: string, keyPath: string }}
34
+ */
35
+ export function getCaPaths() {
36
+ const pkiDir = getPkiDir();
37
+ return {
38
+ certPath: path.join(pkiDir, 'ca.crt'),
39
+ keyPath: path.join(pkiDir, 'ca.key'),
40
+ };
41
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Agent certificate signing with the panel CA.
3
+ *
4
+ * The panel is the CA: the lamaste user owns the PKI directory and the CA key
5
+ * (plugins that issue their own certificates get the key's path too — see
6
+ * plugin-router.js). Signing therefore needs no privileges and runs as the
7
+ * panel itself.
8
+ *
9
+ * The certificate's subject is taken from the CSR as is, and no CSR
10
+ * extension is copied. Callers verify the exact CN they expect before
11
+ * signing; `signAgentCsr` re-checks its shape as a last line — only agent and
12
+ * plugin-agent labels, never `admin` (admin certificates come from
13
+ * `lamaste-server reset-admin` alone).
14
+ */
15
+
16
+ import crypto from 'node:crypto';
17
+ import { execa } from 'execa';
18
+
19
+ const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
20
+
21
+ const LABEL = '[a-z0-9][a-z0-9-]{0,61}[a-z0-9]?';
22
+ const AGENT_CN_RE = new RegExp(`^(?:agent|plugin-agent):${LABEL}(?::${LABEL})?$`);
23
+
24
+ /**
25
+ * A 16-byte (128-bit) random serial as lower-case hex with no leading zero,
26
+ * so it stays a positive integer when openssl reads it as `0x<serial>`.
27
+ *
28
+ * @returns {string}
29
+ */
30
+ export function randomCertSerial() {
31
+ let hex = crypto.randomBytes(16).toString('hex').replace(/^0+/, '');
32
+ if (hex.length === 0) hex = '1';
33
+ return hex;
34
+ }
35
+
36
+ /**
37
+ * Extract the CN from a CSR's subject. Returns the CN string or null.
38
+ *
39
+ * @param {string} csrPath
40
+ * @returns {Promise<string | null>}
41
+ */
42
+ export async function readCsrCN(csrPath) {
43
+ try {
44
+ const { stdout } = await execa('openssl', [
45
+ 'req',
46
+ '-in',
47
+ csrPath,
48
+ '-noout',
49
+ '-subject',
50
+ '-nameopt',
51
+ 'RFC2253',
52
+ ]);
53
+ // Output: "subject=CN=agent:foo,O=Lamaste". RFC2253 form is
54
+ // comma-separated; the agent label charset has no commas to escape.
55
+ const subject = stdout.trim().replace(/^subject=\s*/, '');
56
+ for (const part of subject.split(',')) {
57
+ const trimmed = part.trim();
58
+ if (trimmed.startsWith('CN=')) {
59
+ return trimmed.slice(3);
60
+ }
61
+ }
62
+ return null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Sign the CSR at `csrPath` with the panel CA, writing the certificate to
70
+ * `certPath`.
71
+ *
72
+ * @param {string} csrPath
73
+ * @param {string} certPath
74
+ * @param {string} serial - lower-case hex, see randomCertSerial()
75
+ * @param {number} days
76
+ */
77
+ export async function signAgentCsr(csrPath, certPath, serial, days) {
78
+ const cn = await readCsrCN(csrPath);
79
+ if (!cn || !AGENT_CN_RE.test(cn)) {
80
+ throw Object.assign(new Error(`Refusing to sign a CSR with CN ${cn ?? '(none)'}`), {
81
+ statusCode: 400,
82
+ });
83
+ }
84
+ if (!/^[0-9a-f]{1,32}$/.test(serial)) throw new Error('Invalid certificate serial');
85
+ if (!Number.isInteger(days) || days < 1 || days > 7300) throw new Error('Invalid validity');
86
+ await execa('openssl', [
87
+ 'x509',
88
+ '-req',
89
+ '-in',
90
+ csrPath,
91
+ '-CA',
92
+ `${PKI_DIR}/ca.crt`,
93
+ '-CAkey',
94
+ `${PKI_DIR}/ca.key`,
95
+ '-days',
96
+ String(days),
97
+ '-set_serial',
98
+ `0x${serial}`,
99
+ '-sha256',
100
+ '-out',
101
+ certPath,
102
+ ]);
103
+ }
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Server-side plugin lifecycle.
3
+ *
4
+ * Schema validation and constants are imported from @lamalibre/lamaste (core).
5
+ * This module retains server-specific lifecycle operations (install, uninstall,
6
+ * enable, disable) that use the local promise-chain mutex and execa for npm.
7
+ *
8
+ * Backed by SQLite (`state.db`, table `plugins`) per
9
+ * docs/decisions/sqlite-migration.md §3c.
10
+ */
11
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
12
+ import { createRequire } from 'node:module';
13
+ import path from 'node:path';
14
+ import { z } from 'zod';
15
+ import { execa } from 'execa';
16
+ import {
17
+ RESERVED_API_PREFIXES,
18
+ RESERVED_NAV_LABELS,
19
+ derivePluginRoute,
20
+ validateManifest as coreValidateManifest,
21
+ } from '@lamalibre/lamaste';
22
+ import { revokePluginCapabilitiesFromAgents } from '@lamalibre/lamaste/server';
23
+ import { listRegisteredSubScopeNames } from './tickets.js';
24
+ import { getStateDb } from './state-db.js';
25
+
26
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
27
+ const PLUGINS_DIR = path.join(STATE_DIR, 'plugins');
28
+ const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
29
+
30
+ // Promise-chain mutex. SQLite's BEGIN IMMEDIATE serialises a single write
31
+ // transaction, but install/uninstall are read-modify-write sequences that
32
+ // span multiple statements (and shell out to npm in between). This lock
33
+ // keeps each such sequence atomic. Removable once each operation is
34
+ // collapsed into one transaction.
35
+ let pluginLock = Promise.resolve();
36
+ function withPluginLock(fn) {
37
+ const prev = pluginLock;
38
+ let resolve;
39
+ pluginLock = new Promise((r) => {
40
+ resolve = r;
41
+ });
42
+ return prev.then(fn).finally(resolve);
43
+ }
44
+
45
+ // --- SQLite prepared-statement bundle (lazy init) ---
46
+
47
+ let stmts = null;
48
+
49
+ async function getStmts() {
50
+ if (stmts) return stmts;
51
+
52
+ const db = await getStateDb();
53
+
54
+ stmts = {
55
+ db,
56
+
57
+ selectAll: db.prepare('SELECT * FROM plugins'),
58
+ selectByName: db.prepare('SELECT * FROM plugins WHERE name = ?'),
59
+ selectByPackageName: db.prepare('SELECT * FROM plugins WHERE package_name = ?'),
60
+ insert: db.prepare(`
61
+ INSERT INTO plugins
62
+ (name, display_name, package_name, version, description, capabilities,
63
+ packages, panel, config, modes, status, installed_at, enabled_at)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
65
+ `),
66
+ deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
67
+ deleteAll: db.prepare('DELETE FROM plugins'),
68
+ updateStatusEnable: db.prepare(`
69
+ UPDATE plugins SET status = 'enabled', enabled_at = ? WHERE name = ?
70
+ `),
71
+ updateStatusDisable: db.prepare(`
72
+ UPDATE plugins SET status = 'disabled', enabled_at = NULL WHERE name = ?
73
+ `),
74
+
75
+ begin: db.prepare('BEGIN IMMEDIATE'),
76
+ commit: db.prepare('COMMIT'),
77
+ rollback: db.prepare('ROLLBACK'),
78
+ };
79
+
80
+ return stmts;
81
+ }
82
+
83
+ // --- Row → JS object helper ---
84
+ //
85
+ // JSON-typed columns (capabilities, packages, panel, config, modes) are
86
+ // JSON-encoded TEXT in storage. They round-trip through JSON.parse/stringify
87
+ // so callers see the same shape the JSON-backed module returned — including
88
+ // `undefined` for optional fields (mirroring the JSON object layout where
89
+ // missing keys are absent rather than null).
90
+
91
+ function rowToPlugin(row) {
92
+ const entry = {
93
+ name: row.name,
94
+ packageName: row.package_name,
95
+ version: row.version,
96
+ capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
97
+ status: row.status,
98
+ installedAt: row.installed_at,
99
+ };
100
+ if (row.display_name != null) entry.displayName = row.display_name;
101
+ if (row.description != null) entry.description = row.description;
102
+ if (row.packages != null) entry.packages = JSON.parse(row.packages);
103
+ if (row.panel != null) entry.panel = JSON.parse(row.panel);
104
+ if (row.config != null) entry.config = JSON.parse(row.config);
105
+ if (row.modes != null) entry.modes = JSON.parse(row.modes);
106
+ if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
107
+ return entry;
108
+ }
109
+
110
+ function nullableString(value) {
111
+ return value === undefined || value === null ? null : value;
112
+ }
113
+
114
+ function nullableJson(value) {
115
+ return value === undefined || value === null ? null : JSON.stringify(value);
116
+ }
117
+
118
+ // --- Plugin registry persistence ---
119
+
120
+ /**
121
+ * Read the plugin registry from disk.
122
+ * Returns `{ plugins: [] }` if the file does not exist.
123
+ */
124
+ export async function readPlugins() {
125
+ const s = await getStmts();
126
+ const rows = s.selectAll.all();
127
+ return { plugins: rows.map(rowToPlugin) };
128
+ }
129
+
130
+ /**
131
+ * Write the plugin registry to disk atomically.
132
+ *
133
+ * The function preserves its byte-identical signature from the JSON era: it
134
+ * accepts a `{ plugins: [...] }` shape and replaces the entire registry. The
135
+ * SQLite implementation runs DELETE + INSERTs in one BEGIN IMMEDIATE
136
+ * transaction so observers see either the old or the new state, never a
137
+ * partial write.
138
+ */
139
+ export async function writePlugins(data) {
140
+ const s = await getStmts();
141
+ const list = Array.isArray(data?.plugins) ? data.plugins : [];
142
+
143
+ s.begin.run();
144
+ try {
145
+ s.deleteAll.run();
146
+ for (const p of list) {
147
+ s.insert.run(
148
+ p.name,
149
+ nullableString(p.displayName),
150
+ p.packageName,
151
+ p.version,
152
+ nullableString(p.description),
153
+ JSON.stringify(p.capabilities ?? []),
154
+ nullableJson(p.packages),
155
+ nullableJson(p.panel),
156
+ nullableJson(p.config),
157
+ nullableJson(p.modes),
158
+ p.status,
159
+ p.installedAt,
160
+ nullableString(p.enabledAt),
161
+ );
162
+ }
163
+ s.commit.run();
164
+ } catch (err) {
165
+ s.rollback.run();
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Validate a raw plugin manifest against the schema.
172
+ * Delegates to the core library's ManifestSchema.
173
+ *
174
+ * @param {object} raw - The raw manifest object
175
+ * @returns {object} The validated and parsed manifest
176
+ * @throws {z.ZodError} If validation fails
177
+ */
178
+ export function validateManifest(raw) {
179
+ return coreValidateManifest(raw);
180
+ }
181
+
182
+ /**
183
+ * Install a plugin by npm package name.
184
+ * Only @lamalibre/ scoped packages are allowed.
185
+ *
186
+ * @param {string} packageName - The npm package name (must start with @lamalibre/)
187
+ * @param {import('pino').Logger} logger
188
+ * @returns {Promise<object>} The installed plugin entry
189
+ */
190
+ // Reject npm aliases (e.g. "@lamalibre/x@npm:totally-evil") and any name that
191
+ // could resolve outside the @lamalibre/ scope. Optional version suffix
192
+ // (`@1.2.3`, `@beta`, etc.) is allowed; alias schemes (`@npm:`, `@github:`,
193
+ // etc.) and shell metacharacters are not.
194
+ const LAMALIBRE_PACKAGE_RE = /^@lamalibre\/[a-z0-9][a-z0-9._-]{0,213}(@[A-Za-z0-9._-]+)?$/;
195
+
196
+ function assertSafeLamalibrePackage(packageName) {
197
+ if (typeof packageName !== 'string' || packageName.length === 0 || packageName.length > 256) {
198
+ throw Object.assign(new Error('Invalid package name'), { statusCode: 400 });
199
+ }
200
+ if (!LAMALIBRE_PACKAGE_RE.test(packageName)) {
201
+ throw Object.assign(
202
+ new Error('Only @lamalibre/ scoped packages are allowed (alias schemes are rejected)'),
203
+ { statusCode: 400 },
204
+ );
205
+ }
206
+ // Defense-in-depth against npm alias syntax even if the regex above shifts.
207
+ const versionSuffix = packageName.indexOf('@', 1);
208
+ if (versionSuffix !== -1 && packageName.slice(versionSuffix).startsWith('@npm:')) {
209
+ throw Object.assign(new Error('npm: alias references are not permitted'), { statusCode: 400 });
210
+ }
211
+ }
212
+
213
+ export function installPlugin(packageName, logger) {
214
+ return withPluginLock(async () => {
215
+ // Validate package scope
216
+ assertSafeLamalibrePackage(packageName);
217
+
218
+ const s = await getStmts();
219
+
220
+ // Check if already installed
221
+ const existingRow = s.selectByPackageName.get(packageName);
222
+ if (existingRow) {
223
+ throw Object.assign(new Error(`Plugin "${packageName}" is already installed`), {
224
+ statusCode: 409,
225
+ });
226
+ }
227
+
228
+ // Ensure STATE_DIR has a package.json so npm does not walk up the tree
229
+ const statePackageJson = path.join(STATE_DIR, 'package.json');
230
+ try {
231
+ await readFile(statePackageJson, 'utf-8');
232
+ } catch (err) {
233
+ if (err.code === 'ENOENT') {
234
+ await writeFile(statePackageJson, '{"private":true}\n', { encoding: 'utf-8', mode: 0o600 });
235
+ }
236
+ }
237
+
238
+ // Install the npm package
239
+ logger.info({ packageName }, 'Installing plugin package');
240
+ try {
241
+ await execa('npm', ['install', '--ignore-scripts', packageName], {
242
+ cwd: STATE_DIR,
243
+ timeout: 120000,
244
+ });
245
+ } catch (err) {
246
+ logger.error({ packageName, stderr: err.stderr, err: err.message }, 'npm install failed');
247
+ throw Object.assign(new Error(`Failed to install "${packageName}"`), { statusCode: 500 });
248
+ }
249
+
250
+ // Read the plugin manifest from the installed package
251
+ let manifest;
252
+ try {
253
+ const require = createRequire(`${STATE_DIR}/`);
254
+ const manifestPath = require.resolve(`${packageName}/lamaste-plugin.json`);
255
+ const manifestRaw = await readFile(manifestPath, 'utf-8');
256
+ manifest = validateManifest(JSON.parse(manifestRaw));
257
+ } catch (err) {
258
+ // Clean up the installed package if manifest is missing/invalid
259
+ logger.warn({ packageName, err: err.message }, 'Invalid or missing manifest, uninstalling');
260
+ await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
261
+
262
+ if (err instanceof z.ZodError) {
263
+ throw Object.assign(
264
+ new Error(`Invalid plugin manifest: ${err.errors.map((e) => e.message).join(', ')}`),
265
+ { statusCode: 400 },
266
+ );
267
+ }
268
+ throw Object.assign(new Error(`Plugin manifest not found in "${packageName}"`), {
269
+ statusCode: 400,
270
+ });
271
+ }
272
+
273
+ // Reject names that collide with core API route prefixes
274
+ if (RESERVED_API_PREFIXES.includes(manifest.name)) {
275
+ await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
276
+ throw Object.assign(new Error(`Plugin name "${manifest.name}" is reserved`), {
277
+ statusCode: 400,
278
+ });
279
+ }
280
+
281
+ // Reject displayName that matches reserved navigation labels
282
+ if (manifest.displayName) {
283
+ if (RESERVED_NAV_LABELS.includes(manifest.displayName.toLowerCase())) {
284
+ await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
285
+ throw Object.assign(
286
+ new Error(
287
+ `Plugin display name "${manifest.displayName}" conflicts with a core navigation label`,
288
+ ),
289
+ { statusCode: 400 },
290
+ );
291
+ }
292
+ }
293
+
294
+ // Validate apiPrefix matches plugin name if specified
295
+ if (manifest.panel?.apiPrefix && manifest.panel.apiPrefix !== `/api/${manifest.name}`) {
296
+ await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
297
+ throw Object.assign(
298
+ new Error(
299
+ `Plugin apiPrefix must be "/api/${manifest.name}" but got "${manifest.panel.apiPrefix}"`,
300
+ ),
301
+ { statusCode: 400 },
302
+ );
303
+ }
304
+
305
+ // Cross-namespace collision: any capability declared by the plugin must
306
+ // not match a sub-scope name already owned by a registered ticket scope.
307
+ // Schema-level namespacing puts both sides in `plugin:*`; this check is
308
+ // a flat set intersection. The reverse direction (registering a scope
309
+ // that collides with an installed plugin's cap) is enforced in
310
+ // `registerScope`.
311
+ if (Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0) {
312
+ const subScopeNames = new Set(await listRegisteredSubScopeNames());
313
+ const collision = manifest.capabilities.find((c) => subScopeNames.has(c));
314
+ if (collision) {
315
+ await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
316
+ throw Object.assign(
317
+ new Error(
318
+ `Plugin capability "${collision}" collides with an existing ticket scope sub-scope name. ` +
319
+ 'Pick a different action name or unregister the conflicting ticket scope first.',
320
+ ),
321
+ { statusCode: 409 },
322
+ );
323
+ }
324
+ }
325
+
326
+ // Create the plugin state directory
327
+ const pluginDir = path.join(PLUGINS_DIR, manifest.name);
328
+ await mkdir(pluginDir, { recursive: true, mode: 0o700 });
329
+
330
+ // Build the entry, then persist via INSERT
331
+ const entry = {
332
+ name: manifest.name,
333
+ displayName: manifest.displayName,
334
+ packageName,
335
+ version: manifest.version,
336
+ description: manifest.description,
337
+ capabilities: manifest.capabilities,
338
+ packages: manifest.packages,
339
+ panel: manifest.panel,
340
+ config: manifest.config,
341
+ modes: manifest.modes,
342
+ status: 'disabled',
343
+ installedAt: new Date().toISOString(),
344
+ };
345
+
346
+ s.begin.run();
347
+ try {
348
+ s.insert.run(
349
+ entry.name,
350
+ nullableString(entry.displayName),
351
+ entry.packageName,
352
+ entry.version,
353
+ nullableString(entry.description),
354
+ JSON.stringify(entry.capabilities ?? []),
355
+ nullableJson(entry.packages),
356
+ nullableJson(entry.panel),
357
+ nullableJson(entry.config),
358
+ nullableJson(entry.modes),
359
+ entry.status,
360
+ entry.installedAt,
361
+ null,
362
+ );
363
+ s.commit.run();
364
+ } catch (err) {
365
+ s.rollback.run();
366
+ throw err;
367
+ }
368
+
369
+ logger.info({ name: manifest.name, packageName }, 'Plugin installed');
370
+ return entry;
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Uninstall a plugin. Must be disabled first.
376
+ *
377
+ * @param {string} name - The plugin name
378
+ * @param {import('pino').Logger} logger
379
+ * @returns {Promise<{ ok: true, name: string }>}
380
+ */
381
+ export function uninstallPlugin(name, logger) {
382
+ return withPluginLock(async () => {
383
+ const s = await getStmts();
384
+ const row = s.selectByName.get(name);
385
+
386
+ if (!row) {
387
+ throw Object.assign(new Error(`Plugin "${name}" not found`), { statusCode: 404 });
388
+ }
389
+
390
+ const plugin = rowToPlugin(row);
391
+
392
+ if (plugin.status === 'enabled') {
393
+ throw Object.assign(new Error(`Plugin "${name}" must be disabled before uninstalling`), {
394
+ statusCode: 400,
395
+ });
396
+ }
397
+
398
+ // Uninstall the npm package
399
+ logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
400
+ await execa('npm', ['uninstall', plugin.packageName], {
401
+ cwd: STATE_DIR,
402
+ }).catch((err) => {
403
+ logger.warn({ err: err.message }, 'npm uninstall failed (continuing)');
404
+ });
405
+
406
+ // Remove the plugin state directory
407
+ const pluginDir = path.join(PLUGINS_DIR, name);
408
+ await rm(pluginDir, { recursive: true, force: true }).catch((err) => {
409
+ logger.warn({ err: err.message }, 'Failed to remove plugin state directory');
410
+ });
411
+
412
+ // Remove from registry
413
+ s.begin.run();
414
+ try {
415
+ s.deleteByName.run(name);
416
+ s.commit.run();
417
+ } catch (err) {
418
+ s.rollback.run();
419
+ throw err;
420
+ }
421
+
422
+ // Sweep this plugin's capabilities off every agent so re-installing the
423
+ // plugin later does NOT silently re-grant. Failure here is non-fatal
424
+ // (the plugin is already gone), but logged loudly.
425
+ try {
426
+ const route = derivePluginRoute(plugin.name);
427
+ const diffs = await revokePluginCapabilitiesFromAgents(PKI_DIR, route);
428
+ if (diffs.length > 0) {
429
+ logger.info({ name, route, diffs }, 'Revoked plugin capabilities from agents on uninstall');
430
+ }
431
+ } catch (err) {
432
+ logger.warn(
433
+ { name, err: err.message },
434
+ 'Failed to sweep plugin capabilities from agent registry on uninstall',
435
+ );
436
+ }
437
+
438
+ logger.info({ name }, 'Plugin uninstalled');
439
+ return { ok: true, name };
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Enable a plugin.
445
+ *
446
+ * @param {string} name - The plugin name
447
+ * @param {import('pino').Logger} [logger]
448
+ * @returns {Promise<{ ok: true, name: string, status: string }>}
449
+ */
450
+ export function enablePlugin(name, logger) {
451
+ return withPluginLock(async () => {
452
+ const s = await getStmts();
453
+ const row = s.selectByName.get(name);
454
+
455
+ if (!row) {
456
+ throw Object.assign(new Error(`Plugin "${name}" not found`), { statusCode: 404 });
457
+ }
458
+
459
+ if (row.status === 'enabled') {
460
+ return { ok: true, name, status: 'enabled' };
461
+ }
462
+
463
+ s.begin.run();
464
+ try {
465
+ s.updateStatusEnable.run(new Date().toISOString(), name);
466
+ s.commit.run();
467
+ } catch (err) {
468
+ s.rollback.run();
469
+ throw err;
470
+ }
471
+
472
+ logger?.info({ name }, 'Plugin enabled');
473
+ return { ok: true, name, status: 'enabled' };
474
+ });
475
+ }
476
+
477
+ /**
478
+ * Disable a plugin.
479
+ *
480
+ * @param {string} name - The plugin name
481
+ * @param {import('pino').Logger} [logger]
482
+ * @returns {Promise<{ ok: true, name: string, status: string }>}
483
+ */
484
+ export function disablePlugin(name, logger) {
485
+ return withPluginLock(async () => {
486
+ const s = await getStmts();
487
+ const row = s.selectByName.get(name);
488
+
489
+ if (!row) {
490
+ throw Object.assign(new Error(`Plugin "${name}" not found`), { statusCode: 404 });
491
+ }
492
+
493
+ if (row.status === 'disabled') {
494
+ return { ok: true, name, status: 'disabled' };
495
+ }
496
+
497
+ s.begin.run();
498
+ try {
499
+ s.updateStatusDisable.run(name);
500
+ s.commit.run();
501
+ } catch (err) {
502
+ s.rollback.run();
503
+ throw err;
504
+ }
505
+
506
+ logger?.info({ name }, 'Plugin disabled');
507
+ return { ok: true, name, status: 'disabled' };
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Get all enabled plugins.
513
+ *
514
+ * @returns {Promise<Array>}
515
+ */
516
+ export async function getEnabledPlugins() {
517
+ const s = await getStmts();
518
+ const rows = s.selectAll.all();
519
+ return rows.filter((r) => r.status === 'enabled').map(rowToPlugin);
520
+ }
521
+
522
+ /**
523
+ * Get capabilities contributed by all installed plugins.
524
+ *
525
+ * @returns {Promise<string[]>}
526
+ */
527
+ export async function getPluginCapabilities() {
528
+ const s = await getStmts();
529
+ const rows = s.selectAll.all();
530
+ const caps = [];
531
+ for (const row of rows) {
532
+ if (row.status === 'enabled' && row.capabilities) {
533
+ const parsed = JSON.parse(row.capabilities);
534
+ if (Array.isArray(parsed)) caps.push(...parsed);
535
+ }
536
+ }
537
+ return [...new Set(caps)];
538
+ }