@lamalibre/create-lamaste 4.2.0 → 4.3.1

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 (128) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/scripts/lamaste-priv +205 -2
  4. package/src/index.js +8 -4
  5. package/src/lib/node-version.js +114 -0
  6. package/src/lib/panel-pin.js +41 -0
  7. package/src/lib/relay-services.js +15 -1
  8. package/src/lib/service-config.js +113 -0
  9. package/src/tasks/gatekeeper.js +8 -3
  10. package/src/tasks/node.js +34 -75
  11. package/src/tasks/redeploy.js +36 -0
  12. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  13. package/vendor/docs/00-introduction/quickstart.md +5 -5
  14. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  15. package/vendor/docs/01-concepts/authentication.md +6 -3
  16. package/vendor/docs/01-concepts/authorization.md +29 -17
  17. package/vendor/docs/01-concepts/certificates.md +3 -3
  18. package/vendor/docs/01-concepts/mtls.md +2 -2
  19. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  20. package/vendor/docs/01-concepts/security-model.md +110 -35
  21. package/vendor/docs/01-concepts/tickets.md +2 -2
  22. package/vendor/docs/01-concepts/tunneling.md +4 -4
  23. package/vendor/docs/02-guides/agent-setup.md +34 -16
  24. package/vendor/docs/02-guides/certificate-management.md +2 -2
  25. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  26. package/vendor/docs/02-guides/desktop-app-setup.md +11 -10
  27. package/vendor/docs/02-guides/installation.md +3 -3
  28. package/vendor/docs/02-guides/onboarding.md +2 -2
  29. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  34. package/vendor/docs/03-architecture/overview.md +8 -8
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +2 -2
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  40. package/vendor/docs/04-api-reference/app-host.md +360 -0
  41. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  42. package/vendor/docs/04-api-reference/certificates.md +95 -38
  43. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  44. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  45. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  46. package/vendor/docs/04-api-reference/overview.md +137 -111
  47. package/vendor/docs/04-api-reference/registry-and-oidc.md +8 -4
  48. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  49. package/vendor/docs/05-operations/backup-and-restore.md +3 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +25 -20
  52. package/vendor/docs/06-reference/config-files.md +195 -97
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  57. package/vendor/docs/_index.json +20 -0
  58. package/vendor/docs/gatekeeper-design.md +22 -19
  59. package/vendor/docs/identity-directory-design.md +57 -56
  60. package/vendor/docs/package.json +1 -1
  61. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  62. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  63. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  64. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  65. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  66. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/index.js +47 -8
  68. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  71. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  72. package/vendor/gatekeeper/package.json +4 -3
  73. package/vendor/server/bin/lamaste-server.js +25 -1
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/audit.js +60 -0
  76. package/vendor/server/src/commands/panel-pin.js +40 -0
  77. package/vendor/server/src/commands/plugins.js +963 -12
  78. package/vendor/server/src/commands/push-install.js +317 -0
  79. package/vendor/server/src/commands/registry-credential.js +2 -1
  80. package/vendor/server/src/commands/uninstall.js +28 -1
  81. package/vendor/server/src/ndjson.js +4 -2
  82. package/vendor/server/src/panel-api.js +277 -34
  83. package/vendor/server/src/panel-identity.js +165 -0
  84. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
  85. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  86. package/vendor/server-ui/dist/index.html +2 -2
  87. package/vendor/serverd/package.json +2 -2
  88. package/vendor/serverd/src/index.js +56 -4
  89. package/vendor/serverd/src/lib/actor.js +24 -0
  90. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  91. package/vendor/serverd/src/lib/app-error.js +10 -0
  92. package/vendor/serverd/src/lib/audit.js +145 -0
  93. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  94. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  95. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  96. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  97. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  98. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  99. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  100. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  101. package/vendor/serverd/src/lib/mtls.js +89 -8
  102. package/vendor/serverd/src/lib/nginx.js +298 -44
  103. package/vendor/serverd/src/lib/notifications.js +107 -0
  104. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  105. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  106. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  107. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  108. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  109. package/vendor/serverd/src/lib/plugins.js +298 -68
  110. package/vendor/serverd/src/lib/push-install.js +88 -2
  111. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  112. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  113. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  114. package/vendor/serverd/src/lib/retained-data.js +99 -0
  115. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  116. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  117. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  118. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  119. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  120. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  121. package/vendor/serverd/src/routes/management/agents.js +262 -1
  122. package/vendor/serverd/src/routes/management/audit.js +81 -0
  123. package/vendor/serverd/src/routes/management/certs.js +1 -15
  124. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  125. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  126. package/vendor/serverd/src/routes/management.js +4 -0
  127. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  128. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,560 @@
1
+ /**
2
+ * A plugin's lifecycle hooks on the relay: its relay hook set as installed,
3
+ * the administrator's approval of what Lamaste runs and installs for it,
4
+ * auto-approve, and the hook runner that holds the approved module.
5
+ *
6
+ * Approval binds bytes (see `hook-set.ts` in the core library): one digest
7
+ * (`approvalDigest`) covers the relay hook set — the normalised relay
8
+ * declarations and the SHA-256 of the relay entry module — and the agent
9
+ * half Lamaste installs onto the hosting agent — its package, version,
10
+ * tarball integrity, service and system packages. Approval is needed when
11
+ * either exists. Lamaste runs relay hooks, hosts the plugin and installs its
12
+ * agent half only while the installed version's digest is the approved
13
+ * one.
14
+ *
15
+ * The runner is about the relay part alone: it holds a root-owned copy of
16
+ * the approved module that lamaste-priv installed after checking its
17
+ * SHA-256, and its state (`installedDigest`) names the *relay hook set's*
18
+ * digest (`relayHookSetDigest`) — so a new agent half with an unchanged
19
+ * relay module leaves the runner current.
20
+ */
21
+
22
+ import { readFile } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { execa } from 'execa';
25
+ import {
26
+ agentHalfOf,
27
+ agentHalfPreview,
28
+ agentHookSetOf,
29
+ approvalDigest,
30
+ derivePluginRoute,
31
+ hookEntrySha256,
32
+ relayHookSetDigest,
33
+ relayHookSetNeedsEntry,
34
+ relayHookSetOf,
35
+ relayHookSetPackVersions,
36
+ relayHookSetPreview,
37
+ } from '@lamalibre/lamaste';
38
+ import { HOOK_RUNNER_SOCKET_DIR } from '@lamalibre/lamaste/server';
39
+ import { httpError } from './app-error.js';
40
+ import { getStateDb } from './state-db.js';
41
+ import { recordAudit, recordAuditBestEffort } from './audit.js';
42
+ import { notifyAdminsBestEffort } from './notifications.js';
43
+ import { installedPackageDir, readInstalledHookModule } from './plugin-packages.js';
44
+
45
+ const PRIV = '/usr/local/sbin/lamaste-priv';
46
+ /** Where lamaste-priv puts each plugin's approved module and runner.env (root-owned, world-readable). */
47
+ const HOOKS_DIR = process.env.LAMALIBRE_LAMASTE_HOOKS_DIR || '/opt/lamalibre/lamaste/hooks';
48
+ /** A stuck runner is restarted at most this often. */
49
+ const RUNNER_RESTART_MIN_INTERVAL_MS = 10_000;
50
+
51
+ /** The directory of the runner sockets (systemd's; a test points it elsewhere). */
52
+ export function hookSocketDir() {
53
+ return process.env.LAMALIBRE_LAMASTE_HOOKS_SOCKET_DIR || HOOK_RUNNER_SOCKET_DIR;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The runner manager: lamaste-priv on a relay, replaced in tests.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const privRunnerManager = {
61
+ async install(route, entrySha256, moduleBytes) {
62
+ await execa('sudo', [PRIV, 'hooks', 'install', route, entrySha256 ?? 'none'], {
63
+ ...(moduleBytes ? { input: moduleBytes } : {}),
64
+ timeout: 60_000,
65
+ });
66
+ },
67
+ async activate(route, entrySha256) {
68
+ await execa('sudo', [PRIV, 'hooks', 'activate', route, entrySha256], { timeout: 60_000 });
69
+ },
70
+ async remove(route) {
71
+ await execa('sudo', [PRIV, 'hooks', 'remove', route], { timeout: 60_000 });
72
+ },
73
+ async restart(route) {
74
+ await execa('sudo', [PRIV, 'hooks', 'restart', route], { timeout: 30_000 });
75
+ },
76
+ /** The entry SHA-256 the runner is set up with: null for shared hooks only, undefined when not installed. */
77
+ async installedEntry(route) {
78
+ let text;
79
+ try {
80
+ text = await readFile(path.join(HOOKS_DIR, route, 'runner.env'), 'utf-8');
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ const m = /^LAMASTE_HOOK_RUNNER_ARGS=--entry \S*\/([a-f0-9]{64})\.mjs$/m.exec(text);
85
+ return m ? m[1] : null;
86
+ },
87
+ };
88
+
89
+ let runnerManager = privRunnerManager;
90
+
91
+ /** Replace how runners are installed and restarted (tests run the runner themselves). */
92
+ export function setHookRunnerManagerForTests(manager) {
93
+ runnerManager = manager ?? privRunnerManager;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // State
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function rowToState(row) {
101
+ if (!row) return null;
102
+ return {
103
+ approvedDigest: row.approved_digest,
104
+ approvedVersion: row.approved_version,
105
+ approvedAt: row.approved_at,
106
+ approvedBy: row.approved_by,
107
+ autoApprove: row.auto_approve === 1,
108
+ installedDigest: row.installed_digest,
109
+ packVersions: JSON.parse(row.pack_versions || '{}'),
110
+ pendingUpdate: row.pending_update ? JSON.parse(row.pending_update) : null,
111
+ tunnelFallback: row.tunnel_fallback ? JSON.parse(row.tunnel_fallback) : null,
112
+ };
113
+ }
114
+
115
+ export async function readHookState(plugin) {
116
+ const db = await getStateDb();
117
+ return rowToState(db.prepare('SELECT * FROM plugin_hook_sets WHERE plugin = ?').get(plugin));
118
+ }
119
+
120
+ async function writeHookState(plugin, patch) {
121
+ const db = await getStateDb();
122
+ const current = (await readHookState(plugin)) ?? {
123
+ approvedDigest: null,
124
+ approvedVersion: null,
125
+ approvedAt: null,
126
+ approvedBy: null,
127
+ autoApprove: false,
128
+ installedDigest: null,
129
+ packVersions: {},
130
+ pendingUpdate: null,
131
+ tunnelFallback: null,
132
+ };
133
+ const next = { ...current, ...patch };
134
+ db.prepare(
135
+ `INSERT INTO plugin_hook_sets
136
+ (plugin, approved_digest, approved_version, approved_at, approved_by, auto_approve,
137
+ installed_digest, pack_versions, pending_update, tunnel_fallback)
138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
139
+ ON CONFLICT(plugin) DO UPDATE SET
140
+ approved_digest = excluded.approved_digest,
141
+ approved_version = excluded.approved_version,
142
+ approved_at = excluded.approved_at,
143
+ approved_by = excluded.approved_by,
144
+ auto_approve = excluded.auto_approve,
145
+ installed_digest = excluded.installed_digest,
146
+ pack_versions = excluded.pack_versions,
147
+ pending_update = excluded.pending_update,
148
+ tunnel_fallback = excluded.tunnel_fallback`,
149
+ ).run(
150
+ plugin,
151
+ next.approvedDigest,
152
+ next.approvedVersion,
153
+ next.approvedAt,
154
+ next.approvedBy,
155
+ next.autoApprove ? 1 : 0,
156
+ next.installedDigest,
157
+ JSON.stringify(next.packVersions ?? {}),
158
+ next.pendingUpdate ? JSON.stringify(next.pendingUpdate) : null,
159
+ next.tunnelFallback ? JSON.stringify(next.tunnelFallback) : null,
160
+ );
161
+ return next;
162
+ }
163
+
164
+ /** Put back a state read earlier with {@link readHookState} (reverting an update). */
165
+ export async function restoreHookState(plugin, state) {
166
+ if (state === null) {
167
+ await deleteHookState(plugin);
168
+ return;
169
+ }
170
+ await writeHookState(plugin, { ...state, pendingUpdate: null });
171
+ }
172
+
173
+ export async function deleteHookState(plugin) {
174
+ const db = await getStateDb();
175
+ db.prepare('DELETE FROM plugin_hook_sets WHERE plugin = ?').run(plugin);
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // What an installed plugin brings, and its approval
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * The relay hook set of an installed plugin (registry entry), with the
184
+ * digest of that set alone (`relayHookSetDigest` — what the runner holds);
185
+ * null when the plugin declares no relay hook.
186
+ */
187
+ export function installedRelayHooks(plugin) {
188
+ const set = relayHookSetOf({ hooks: plugin.hooks });
189
+ if (!set) return null;
190
+ const entrySha256 = relayHookSetNeedsEntry(set) ? (plugin.hooksEntrySha256 ?? null) : null;
191
+ return { set, entrySha256, digest: relayHookSetDigest(set, entrySha256) };
192
+ }
193
+
194
+ /**
195
+ * The agent half Lamaste installs for a plugin (registry entry, or a staged
196
+ * version in the same shape): the manifest's declaration with the version
197
+ * and tarball integrity pinned when that version was staged. Null when the
198
+ * manifest names no `agent.package`. A declaration without a pin for this
199
+ * version is refused — nothing unpinned is ever approved or installed.
200
+ *
201
+ * @returns {import('@lamalibre/lamaste').AgentHalfSpec | null}
202
+ */
203
+ export function installedAgentHalf(plugin) {
204
+ const declaration = agentHalfOf({ agent: plugin.agent });
205
+ if (!declaration) return null;
206
+ const pin = plugin.agentHalfPin;
207
+ if (!pin || pin.package !== declaration.package || pin.version !== plugin.version) {
208
+ throw httpError(
209
+ `The agent half of "${plugin.name}" ${plugin.version} has no pinned integrity — reinstall the plugin`,
210
+ 409,
211
+ );
212
+ }
213
+ return { ...declaration, version: pin.version, integrity: pin.integrity };
214
+ }
215
+
216
+ /**
217
+ * What approving a plugin version covers — its relay hook set and the
218
+ * agent half Lamaste installs — and the digest the approval names. `digest`
219
+ * is null when there is nothing to approve.
220
+ */
221
+ export function installedApproval(plugin) {
222
+ const relay = installedRelayHooks(plugin);
223
+ const agentHalf = installedAgentHalf(plugin);
224
+ const required = relay !== null || agentHalf !== null;
225
+ return {
226
+ relay,
227
+ agentHalf,
228
+ required,
229
+ digest: required
230
+ ? approvalDigest({
231
+ relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
232
+ agentHalf,
233
+ })
234
+ : null,
235
+ };
236
+ }
237
+
238
+ /** Whether `state` approves the installed version (nothing to approve counts as approved). */
239
+ function isApproved(approval, state) {
240
+ return !approval.required || state?.approvedDigest === approval.digest;
241
+ }
242
+
243
+ /**
244
+ * Whether the installed version is approved — it brings neither relay hooks
245
+ * nor an agent half Lamaste installs, or its digest is the approved one.
246
+ * Relay hooks run, and the plugin is hosted, only then.
247
+ */
248
+ export async function installedVersionApproved(plugin) {
249
+ const approval = installedApproval(plugin);
250
+ if (!approval.required) return true;
251
+ return isApproved(approval, await readHookState(plugin.name));
252
+ }
253
+
254
+ /** What the panel shows about a plugin's hooks, its agent half and their approval. */
255
+ export async function hookStatus(plugin) {
256
+ const approval = installedApproval(plugin);
257
+ const { relay, agentHalf } = approval;
258
+ const agent = agentHookSetOf({ hooks: plugin.hooks });
259
+ const state = await readHookState(plugin.name);
260
+ const approved = isApproved(approval, state);
261
+ return {
262
+ relay: relay ? relayHookSetPreview(relay.set, relay.entrySha256) : null,
263
+ agent,
264
+ agentHalf: agentHalf ? agentHalfPreview(derivePluginRoute(plugin.name), agentHalf) : null,
265
+ approval: {
266
+ required: approval.required,
267
+ state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
268
+ // The digest to approve the installed version with (relay hooks and agent half).
269
+ digest: approval.digest,
270
+ approvedDigest: state?.approvedDigest ?? null,
271
+ approvedVersion: state?.approvedVersion ?? null,
272
+ approvedAt: state?.approvedAt ?? null,
273
+ approvedBy: state?.approvedBy ?? null,
274
+ },
275
+ autoApprove: state?.autoApprove ?? false,
276
+ runner: {
277
+ installedDigest: state?.installedDigest ?? null,
278
+ current: relay !== null && approved && state?.installedDigest === relay.digest,
279
+ },
280
+ pendingUpdate: state?.pendingUpdate ? pendingUpdateView(state.pendingUpdate) : null,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * A staged update waiting for approval as clients see it — in the hook
286
+ * status and in the update response alike: its version, the digest to
287
+ * approve it with, what it brings (its relay hook set and the agent half
288
+ * Lamaste installs, each null when it brings none), and — when the plugin
289
+ * has auto-approve on — why auto-approve did not cover it
290
+ * (`autoApproveWithheld`, null otherwise). The stored record names the
291
+ * relay hook set's preview `preview`.
292
+ */
293
+ export function pendingUpdateView(pending) {
294
+ return {
295
+ version: pending.version,
296
+ digest: pending.digest,
297
+ stagedAt: pending.stagedAt,
298
+ relay: pending.preview ?? null,
299
+ agentHalf: pending.agentHalf ?? null,
300
+ autoApproveWithheld: pending.autoApproveWithheld ?? null,
301
+ };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // The runner
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Put the plugin's approved relay hook module into its runner when the
310
+ * runner does not hold it — after an approval, an update, and at start-up
311
+ * (a redeploy may have reset the install directory). Reads the module from
312
+ * the installed package and refuses it when its SHA-256 is not the one the
313
+ * approval names. Returns whether anything changed.
314
+ */
315
+ export async function ensureRelayHooksInstalled(plugin, logger) {
316
+ const approval = installedApproval(plugin);
317
+ const { relay } = approval;
318
+ if (!relay) return false;
319
+ const state = await readHookState(plugin.name);
320
+ if (!isApproved(approval, state)) return false;
321
+ const installedEntry = await runnerManager.installedEntry(plugin.name);
322
+ if (installedEntry !== undefined && installedEntry === relay.entrySha256) {
323
+ if (state.installedDigest !== relay.digest) {
324
+ await writeHookState(plugin.name, { installedDigest: relay.digest });
325
+ }
326
+ return false;
327
+ }
328
+ let moduleBytes = null;
329
+ if (relay.entrySha256 !== null) {
330
+ const dir = await installedPackageDir(plugin.packageName);
331
+ moduleBytes = await readInstalledHookModule(dir, relay.set.entry);
332
+ if (hookEntrySha256(moduleBytes) !== relay.entrySha256) {
333
+ throw httpError(
334
+ `The relay hook module of "${plugin.name}" changed on disk since it was approved — reinstall the plugin`,
335
+ 409,
336
+ );
337
+ }
338
+ }
339
+ await runnerManager.install(plugin.name, relay.entrySha256, moduleBytes);
340
+ await writeHookState(plugin.name, { installedDigest: relay.digest });
341
+ logger?.info({ plugin: plugin.name, digest: relay.digest }, 'Relay hook runner installed');
342
+ return true;
343
+ }
344
+
345
+ /**
346
+ * Put a module whose hook set is already approved into the plugin's runner
347
+ * — an update, before its package is installed (the new version's relay
348
+ * stages run from it) — and record that set (`digest`, the relay hook set's
349
+ * own digest) as the one the runner holds. `moduleBytes` null: shared hooks
350
+ * only.
351
+ *
352
+ * @param {string} plugin
353
+ * @param {{ digest: string, entrySha256: string | null, moduleBytes: Buffer | null }} module
354
+ */
355
+ export async function installRunnerModule(plugin, { digest, entrySha256, moduleBytes }) {
356
+ await runnerManager.install(plugin, entrySha256, moduleBytes);
357
+ await writeHookState(plugin, { installedDigest: digest });
358
+ }
359
+
360
+ /**
361
+ * Point the runner back at a module it kept (the previous one, of relay hook
362
+ * set `digest`) — reverting an update — and record that set as the one it
363
+ * holds again. lamaste-priv keeps the active module and the one before it.
364
+ *
365
+ * @param {string} plugin
366
+ * @param {{ digest: string, entrySha256: string | null }} module
367
+ */
368
+ export async function restoreRunnerModule(plugin, { digest, entrySha256 }) {
369
+ if (entrySha256 === null) await runnerManager.install(plugin, null, null);
370
+ else await runnerManager.activate(plugin, entrySha256);
371
+ await writeHookState(plugin, { installedDigest: digest });
372
+ }
373
+
374
+ /** Remove the plugin's runner, keeping its approval state. */
375
+ export async function removeRunner(plugin) {
376
+ if ((await runnerManager.installedEntry(plugin)) !== undefined)
377
+ await runnerManager.remove(plugin);
378
+ if (await readHookState(plugin)) await writeHookState(plugin, { installedDigest: null });
379
+ }
380
+
381
+ /** Remove the plugin's runner and its approval state (uninstall). */
382
+ export async function removeRelayHooks(plugin, logger) {
383
+ const state = await readHookState(plugin);
384
+ if (state?.installedDigest || (await runnerManager.installedEntry(plugin)) !== undefined) {
385
+ await runnerManager.remove(plugin);
386
+ logger?.info({ plugin }, 'Relay hook runner removed');
387
+ }
388
+ await deleteHookState(plugin);
389
+ }
390
+
391
+ const lastRestart = new Map();
392
+
393
+ /**
394
+ * Restart a runner that let a hook overrun its deadline — a hook in a busy
395
+ * loop blocks the whole runner, so the next call must get a fresh one. At
396
+ * most once per {@link RUNNER_RESTART_MIN_INTERVAL_MS} per plugin.
397
+ */
398
+ export async function restartRelayHookRunner(plugin, logger) {
399
+ const now = Date.now();
400
+ if (now - (lastRestart.get(plugin) ?? 0) < RUNNER_RESTART_MIN_INTERVAL_MS) return false;
401
+ lastRestart.set(plugin, now);
402
+ try {
403
+ await runnerManager.restart(plugin);
404
+ logger?.warn({ plugin }, 'Restarted a relay hook runner after a hook timed out');
405
+ return true;
406
+ } catch (err) {
407
+ logger?.error({ plugin, err: err.message }, 'Failed to restart the relay hook runner');
408
+ return false;
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Approval
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /**
417
+ * An administrator approves what a plugin version brings — its relay hook
418
+ * set and the agent half Lamaste installs — with the digest they were
419
+ * shown. The digest must be the installed version's — or the pending
420
+ * update's, in which case the caller applies the update. Recorded in the
421
+ * audit log.
422
+ *
423
+ * @returns {Promise<{ target: 'installed' | 'update', digest: string }>}
424
+ */
425
+ export async function approveHookSet(plugin, digest, { actor, certSerial, logger }) {
426
+ const approval = installedApproval(plugin);
427
+ const { relay } = approval;
428
+ const state = await readHookState(plugin.name);
429
+ const pending = state?.pendingUpdate ?? null;
430
+ const now = new Date().toISOString();
431
+
432
+ if (pending && pending.digest === digest) {
433
+ await recordAudit({
434
+ actor,
435
+ action: 'hooks.approved',
436
+ plugin: plugin.name,
437
+ details: { digest, version: pending.version, for: 'update', certSerial },
438
+ });
439
+ return { target: 'update', digest };
440
+ }
441
+ if (!approval.required) {
442
+ throw httpError(
443
+ `Plugin "${plugin.name}" has no relay hooks and no agent half Lamaste installs — nothing to approve`,
444
+ 400,
445
+ );
446
+ }
447
+ if (approval.digest !== digest) {
448
+ throw httpError('The hook set changed since it was shown — review it again', 409, {
449
+ currentDigest: approval.digest,
450
+ });
451
+ }
452
+ await writeHookState(plugin.name, {
453
+ approvedDigest: digest,
454
+ approvedVersion: plugin.version,
455
+ approvedAt: now,
456
+ approvedBy: actor,
457
+ packVersions: relay ? relayHookSetPackVersions(relay.set) : {},
458
+ });
459
+ await recordAudit({
460
+ actor,
461
+ action: 'hooks.approved',
462
+ plugin: plugin.name,
463
+ details: { digest, version: plugin.version, for: 'installed', certSerial },
464
+ });
465
+ await ensureRelayHooksInstalled(plugin, logger);
466
+ return { target: 'installed', digest };
467
+ }
468
+
469
+ /**
470
+ * Record an approval of an applied update (by an admin or auto-approve):
471
+ * `digest` is the version's approval digest (`installedApproval`).
472
+ */
473
+ export async function recordApprovedSet(plugin, digest, version, approvedBy) {
474
+ const relay = installedRelayHooks(plugin);
475
+ await writeHookState(plugin.name, {
476
+ approvedDigest: digest,
477
+ approvedVersion: version,
478
+ approvedAt: new Date().toISOString(),
479
+ approvedBy,
480
+ pendingUpdate: null,
481
+ packVersions: relay ? relayHookSetPackVersions(relay.set) : {},
482
+ });
483
+ }
484
+
485
+ export async function setPendingUpdate(plugin, pendingUpdate) {
486
+ await writeHookState(plugin, { pendingUpdate });
487
+ }
488
+
489
+ /** The fallback the plugin's `tunnel:down` hook returned (null clears it, on `tunnel:up`). */
490
+ export async function setTunnelFallback(plugin, fallback) {
491
+ await writeHookState(plugin, { tunnelFallback: fallback ?? null });
492
+ }
493
+
494
+ export async function setAutoApprove(plugin, enabled, { actor, certSerial }) {
495
+ const state = await readHookState(plugin);
496
+ if ((state?.autoApprove ?? false) === enabled) return { autoApprove: enabled };
497
+ await writeHookState(plugin, { autoApprove: enabled });
498
+ await recordAudit({
499
+ actor,
500
+ action: enabled ? 'hooks.auto-approve.enabled' : 'hooks.auto-approve.disabled',
501
+ plugin,
502
+ details: { certSerial },
503
+ });
504
+ return { autoApprove: enabled };
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Start-up
509
+ // ---------------------------------------------------------------------------
510
+
511
+ /**
512
+ * At start-up: make sure each approved runner holds its module, and audit
513
+ * — once per plugin — a shared-hook rule pack that changed with this Lamaste
514
+ * version (rules ride Lamaste releases and need no re-approval).
515
+ */
516
+ export async function reconcileRelayHooks(plugins, logger) {
517
+ for (const plugin of plugins) {
518
+ try {
519
+ const approval = installedApproval(plugin);
520
+ const { relay } = approval;
521
+ if (!relay) continue;
522
+ const state = await readHookState(plugin.name);
523
+ if (!isApproved(approval, state)) {
524
+ logger?.info({ plugin: plugin.name }, 'Relay hooks wait for approval');
525
+ continue;
526
+ }
527
+ await ensureRelayHooksInstalled(plugin, logger);
528
+ const packs = relayHookSetPackVersions(relay.set);
529
+ const before = state.packVersions ?? {};
530
+ const changed = Object.keys(packs).filter((p) => before[p] !== packs[p]);
531
+ if (changed.length > 0) {
532
+ await writeHookState(plugin.name, { packVersions: packs });
533
+ const from = changed.map((p) => `${p}@${before[p] ?? '-'}`).join(',');
534
+ const to = changed.map((p) => `${p}@${packs[p]}`).join(',');
535
+ await recordAuditBestEffort(
536
+ {
537
+ actor: 'lamaste',
538
+ action: 'hooks.rules-updated',
539
+ plugin: plugin.name,
540
+ details: { from, to },
541
+ },
542
+ logger,
543
+ );
544
+ await notifyAdminsBestEffort(
545
+ {
546
+ source: 'lamaste',
547
+ level: 'info',
548
+ message: `Secret-scan rules used by ${plugin.name} were updated with Lamaste (${to}).`,
549
+ },
550
+ logger,
551
+ );
552
+ }
553
+ } catch (err) {
554
+ logger?.error(
555
+ { plugin: plugin.name, err: err.message },
556
+ 'Failed to reconcile the relay hook runner',
557
+ );
558
+ }
559
+ }
560
+ }