@lamalibre/create-lamaste 4.2.0 → 4.3.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 (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Hosting an agent-hosted plugin's agent half on an agent — the daemon's
3
+ * wiring of `@lamalibre/lamaste/server`'s plugin hosting store, the internal
4
+ * tunnel workflow, the agent registry and the health monitor.
5
+ *
6
+ * Hosting a plugin on an agent is three things done together, and undone
7
+ * together: an internal plugin tunnel carried by that agent (its port is
8
+ * assigned from the internal range), a hosting record with a fresh
9
+ * installation secret, and the `plugin:<route>:host` capability on the
10
+ * agent's certificate — the only thing that lets the agent fetch the secret.
11
+ *
12
+ * When Lamaste installs the agent half itself (`agent.package`, slice 3) the
13
+ * relay has the agent install, switch or remove code as root. Hosting,
14
+ * updating and removing such a hosting are therefore pushes: the agent must
15
+ * be able to host it (`hostEligibility` over the host facts it reported)
16
+ * and the push-install gates must allow the action on the agent package
17
+ * (global switch, the agent's window, the source IP, the policy). A plugin
18
+ * whose agent half is installed by hand is hosted as before, unchecked.
19
+ * The agent's helper runs only app jobs this relay signed, so it must also
20
+ * trust the key the relay signs with (the agent reports the one pinned).
21
+ */
22
+
23
+ import {
24
+ AGENT_LABEL_REGEX,
25
+ PLUGIN_AGENT_CN_PREFIX,
26
+ agentHookSetOf,
27
+ derivePluginRoute,
28
+ hasAgentHalf,
29
+ hostEligibility,
30
+ pluginHostCapability,
31
+ } from '@lamalibre/lamaste';
32
+ import {
33
+ TUNNEL_MAX_BODY_MB,
34
+ getPluginHosting,
35
+ pluginHostingOwner,
36
+ readPluginHostings,
37
+ rotatePluginHostingSecret,
38
+ acknowledgePluginHostingSecret,
39
+ updatePluginHostingHealth,
40
+ signingSecretFor,
41
+ pluginHostingView,
42
+ agentPluginConfigFor,
43
+ agentPluginAssignments,
44
+ startPluginHealthMonitor,
45
+ pluginHostingPhase,
46
+ PluginHostingError,
47
+ } from '@lamalibre/lamaste/server';
48
+ import { httpError } from './app-error.js';
49
+ import { getConfig } from './config.js';
50
+ import { readTunnels } from './state.js';
51
+ import { loadAgentRegistry, saveAgentRegistry, withRegistryLock } from './mtls.js';
52
+ import { startPluginOperation } from './plugin-operations.js';
53
+ import {
54
+ pipelineContext,
55
+ runInstallPipeline,
56
+ runPurgeRetainedData,
57
+ runUninstallPipeline,
58
+ } from './hook-pipelines.js';
59
+ import { installedAgentHalf, installedVersionApproved } from './plugin-hooks.js';
60
+ import { checkPushInstallAction, logPushInstallSession } from './push-install.js';
61
+ import { relaySigner } from './relay-signing.js';
62
+ import { findRetainedData, forgetRetainedData, listRetainedData } from './retained-data.js';
63
+ import { recordAudit } from './audit.js';
64
+ import { onHealthTransition } from './tunnel-hooks.js';
65
+
66
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
67
+
68
+ /**
69
+ * The installed plugin `name` must be agent-hosted with an agent half, and
70
+ * hold its route — the route keys its hosting and everything the hosting
71
+ * creates, on the relay and on the agent.
72
+ * @param {string} name
73
+ */
74
+ async function requireHostablePlugin(name) {
75
+ const { findPlugin } = await import('./plugins.js');
76
+ const plugin = await findPlugin(name);
77
+ if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
78
+ if (!hasAgentHalf(plugin)) {
79
+ throw httpError(
80
+ plugin.hosting === 'agent'
81
+ ? `Plugin "${name}" has no agent half (its manifest declares no "agent" block)`
82
+ : `Plugin "${name}" is a server plugin and runs on the panel`,
83
+ 400,
84
+ );
85
+ }
86
+ if (plugin.route === null) {
87
+ throw httpError(
88
+ `Plugin "${name}" shares the route "${derivePluginRoute(name)}" with another installed plugin (both were installed before routes were unique) — uninstall one of them, then reinstall`,
89
+ 409,
90
+ { code: 'route_conflict' },
91
+ );
92
+ }
93
+ if (plugin.route !== plugin.name) {
94
+ throw httpError(
95
+ `A plugin with an agent half is named by its route: "${name}" would be served as "${plugin.route}" — reinstall it named "${plugin.route}"`,
96
+ 409,
97
+ { code: 'route_conflict' },
98
+ );
99
+ }
100
+ return plugin;
101
+ }
102
+
103
+ /**
104
+ * The hosting installed plugin `plugin` owns, or null. A hosting on its
105
+ * route that names another plugin as its owner is not its own — no
106
+ * operation of one plugin reads, changes or removes another's hosting.
107
+ *
108
+ * @param {{ name: string, route: string | null }} plugin registry entry
109
+ */
110
+ export async function ownHosting(plugin) {
111
+ if (!plugin.route) return null;
112
+ const record = await getPluginHosting(STATE_DIR, plugin.route);
113
+ return record && pluginHostingOwner(record) === plugin.name ? record : null;
114
+ }
115
+
116
+ /** {@link ownHosting} of installed plugin `name`; null when it is not installed. */
117
+ async function ownHostingOf(name) {
118
+ const { findPlugin } = await import('./plugins.js');
119
+ const plugin = await findPlugin(name);
120
+ return plugin ? ownHosting(plugin) : null;
121
+ }
122
+
123
+ /** Add or remove one capability on an agent's certificate record, under the registry lock. */
124
+ export function setAgentCapability(label, capability, present) {
125
+ return withRegistryLock(async () => {
126
+ const registry = await loadAgentRegistry();
127
+ const agent = registry.agents.find((a) => a.label === label && !a.revoked);
128
+ if (!agent) throw httpError(`Agent "${label}" is not enrolled or has been revoked`, 400);
129
+ const current = Array.isArray(agent.capabilities) ? agent.capabilities : ['tunnels:read'];
130
+ const has = current.includes(capability);
131
+ if (present === has) return;
132
+ agent.capabilities = present
133
+ ? [...current, capability]
134
+ : current.filter((c) => c !== capability);
135
+ await saveAgentRegistry(registry);
136
+ });
137
+ }
138
+
139
+ export async function isActiveAgent(label) {
140
+ const registry = await loadAgentRegistry();
141
+ return registry.agents.some((a) => a.label === label && !a.revoked);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Pushing an agent half: eligibility and the push-install gates
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** The host facts agent `label` last reported, or null. */
149
+ async function agentHostFacts(label) {
150
+ const registry = await loadAgentRegistry();
151
+ return registry.agents.find((a) => a.label === label && !a.revoked)?.hostFacts ?? null;
152
+ }
153
+
154
+ /**
155
+ * What this relay brings to eligibility: the id of the key it signs app
156
+ * jobs with — null when the key is unusable (it signs nothing then).
157
+ *
158
+ * @returns {Promise<import('@lamalibre/lamaste').HostEligibilityRelay>}
159
+ */
160
+ export async function relayEligibility() {
161
+ try {
162
+ return { relayKid: (await relaySigner.current())?.kid ?? null };
163
+ } catch {
164
+ return { relayKid: null };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Refuse (409 `agent_not_eligible`, with the reasons) an agent that cannot
170
+ * host agent half `agentHalf` of a plugin requiring `requires`.
171
+ */
172
+ export async function assertAgentEligible(label, requires, agentHalf, pluginName) {
173
+ const { eligible, reasons } = hostEligibility(
174
+ requires,
175
+ agentHalf,
176
+ await agentHostFacts(label),
177
+ await relayEligibility(),
178
+ );
179
+ if (!eligible) {
180
+ throw httpError(
181
+ `Agent "${label}" cannot host plugin "${pluginName}": ${reasons.join('; ')}`,
182
+ 409,
183
+ { code: 'agent_not_eligible', reasons },
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The push-install gates for `action` on agent half package `packageName`
190
+ * to agent `label`, from `sourceIp`. Refused with the gate's status and code
191
+ * `push_install_refused`. Returns the push to log once its operation runs.
192
+ */
193
+ export async function assertAgentHalfPush(label, sourceIp, action, packageName) {
194
+ const gate = await checkPushInstallAction(label, sourceIp, action, packageName);
195
+ if (!gate.ok) {
196
+ throw httpError(gate.error, gate.statusCode, { code: 'push_install_refused' });
197
+ }
198
+ return { agentLabel: label, action, packageName, sourceIp, policyId: gate.policy.id };
199
+ }
200
+
201
+ /** Record a push in the push-install session log (best effort: its operation already runs). */
202
+ export async function logAgentHalfPush(push, operationId, logger) {
203
+ if (!push) return;
204
+ try {
205
+ await logPushInstallSession({ ...push, status: 'started', operationId });
206
+ } catch (err) {
207
+ logger?.warn({ err: err.message }, 'Failed to log the push-install session');
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The checks before a hosting is removed (unhost, or uninstalling a hosted
213
+ * plugin). `purge` applies to an agent half Lamaste installs only. Removing
214
+ * one is a push (`uninstall`) — unless its agent is no longer enrolled:
215
+ * nothing can be pushed to it then, the pipeline skips the removal and says
216
+ * how to finish it on the machine. Returns the push to log, or null.
217
+ *
218
+ * @param {object} plugin registry entry
219
+ * @param {{ agentLabel: string }} record the hosting (the plugin's own — see {@link ownHosting})
220
+ * @param {{ purge: boolean, sourceIp: string }} opts
221
+ */
222
+ export async function prepareHostingRemoval(plugin, record, { purge, sourceIp }) {
223
+ if (pluginHostingOwner(record) !== plugin.name) {
224
+ throw httpError(
225
+ `The hosting on route "${record.plugin}" is not plugin "${plugin.name}"'s`,
226
+ 409,
227
+ {
228
+ code: 'hosting_owner_mismatch',
229
+ },
230
+ );
231
+ }
232
+ const agentHalf = installedAgentHalf(plugin);
233
+ if (!agentHalf) {
234
+ if (purge) {
235
+ throw httpError(
236
+ `purge removes the data of an agent half Lamaste installed — the agent half of "${plugin.name}" is installed by hand`,
237
+ 400,
238
+ );
239
+ }
240
+ return null;
241
+ }
242
+ if (!(await isActiveAgent(record.agentLabel))) return null;
243
+ return assertAgentHalfPush(record.agentLabel, sourceIp, 'uninstall', agentHalf.package);
244
+ }
245
+
246
+ /**
247
+ * Which machine agents can host plugin `name` and, for each that cannot,
248
+ * why — what the host dialog lists. For a plugin whose agent half is
249
+ * installed by hand every enrolled agent is listed as eligible: hosting
250
+ * checks nothing then (as before slice 3).
251
+ *
252
+ * @param {string} name
253
+ * @returns {Promise<Array<{ label: string, eligible: boolean, reasons: string[] }>>}
254
+ */
255
+ export async function hostingEligibility(name) {
256
+ const plugin = await requireHostablePlugin(name);
257
+ const agentHalf = installedAgentHalf(plugin);
258
+ const registry = await loadAgentRegistry();
259
+ const relay = agentHalf ? await relayEligibility() : null;
260
+ return registry.agents
261
+ .filter((a) => !a.revoked && AGENT_LABEL_REGEX.test(a.label))
262
+ .sort((a, b) => a.label.localeCompare(b.label))
263
+ .map((a) => {
264
+ if (!agentHalf) return { label: a.label, eligible: true, reasons: [] };
265
+ const { eligible, reasons } = hostEligibility(
266
+ plugin.agent?.requires,
267
+ agentHalf,
268
+ a.hostFacts ?? null,
269
+ relay,
270
+ );
271
+ return { label: a.label, eligible, reasons };
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Host plugin `name` on agent `agentLabel`: start the install pipeline
277
+ * (`hook-pipelines.js`) as an operation and return it. Checked first, so a
278
+ * request that cannot succeed is refused at once: the plugin is installed
279
+ * and has an agent half, the agent is enrolled, the plugin is not hosted,
280
+ * the relay is onboarded, and what it brings (relay hooks, an agent half
281
+ * Lamaste installs) is approved. An agent half Lamaste installs also needs
282
+ * an eligible agent and the push-install gates (`sourceIp`: the admin's).
283
+ *
284
+ * @param {string} name
285
+ * @param {string} agentLabel
286
+ * @param {{ actor: string, logger: import('pino').Logger, sourceIp: string }} opts
287
+ * @returns {Promise<object>} the operation
288
+ */
289
+ export async function hostPlugin(name, agentLabel, { actor, logger, sourceIp }) {
290
+ const plugin = await requireHostablePlugin(name);
291
+ const route = derivePluginRoute(plugin.name);
292
+ if (!AGENT_LABEL_REGEX.test(agentLabel) || agentLabel.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
293
+ throw httpError('agentLabel must name an enrolled machine agent', 400);
294
+ }
295
+ if (!(await isActiveAgent(agentLabel))) {
296
+ throw httpError(`Agent "${agentLabel}" is not enrolled or has been revoked`, 400);
297
+ }
298
+ const existing = await getPluginHosting(STATE_DIR, route);
299
+ if (existing) {
300
+ const owner = pluginHostingOwner(existing);
301
+ throw owner === plugin.name
302
+ ? httpError(`Plugin "${name}" is already hosted — clear its hosting first`, 409)
303
+ : httpError(`The route "${route}" is hosted for plugin "${owner}"`, 409, {
304
+ code: 'hosting_owner_mismatch',
305
+ });
306
+ }
307
+ const config = getConfig();
308
+ if (!config.domain || !config.email) {
309
+ throw httpError('Domain and email must be configured before hosting a plugin', 400);
310
+ }
311
+ if (!(await installedVersionApproved(plugin))) {
312
+ throw httpError(
313
+ `Plugin "${name}" brings relay hooks or an agent half that are not approved — review and approve them first`,
314
+ 409,
315
+ { code: 'hooks_not_approved' },
316
+ );
317
+ }
318
+ let push = null;
319
+ const agentHalf = installedAgentHalf(plugin);
320
+ if (agentHalf) {
321
+ await assertAgentEligible(agentLabel, plugin.agent?.requires, agentHalf, plugin.name);
322
+ await assertNoForeignRetainedData(agentLabel, route, agentHalf.package);
323
+ push = await assertAgentHalfPush(agentLabel, sourceIp, 'install', agentHalf.package);
324
+ }
325
+
326
+ const ctx = pipelineContext({
327
+ plugin,
328
+ agentLabel,
329
+ domain: config.domain,
330
+ email: config.email,
331
+ logger,
332
+ });
333
+ const { operation } = await startPluginOperation({
334
+ plugin: plugin.name,
335
+ kind: 'host',
336
+ actor,
337
+ params: { agentLabel },
338
+ logger,
339
+ run: (op) => runInstallPipeline(op, ctx),
340
+ });
341
+ await logAgentHalfPush(push, operation.id, logger);
342
+ return operation;
343
+ }
344
+
345
+ /**
346
+ * Remove plugin `name`'s hosting: start the uninstall pipeline as an
347
+ * operation. `force` lets failing pre-teardown stages pass (reported and
348
+ * audited) — for an agent that is gone or a half that no longer answers.
349
+ * `purge` removes the data directory and service account of an agent half
350
+ * Lamaste installed (kept by default).
351
+ *
352
+ * @param {string} name
353
+ * @param {{ actor: string, logger: import('pino').Logger, force?: boolean, purge?: boolean, sourceIp: string }} opts
354
+ * @returns {Promise<object>} the operation
355
+ */
356
+ export async function unhostPlugin(
357
+ name,
358
+ { actor, logger, force = false, purge = false, sourceIp },
359
+ ) {
360
+ const plugin = await requireHostablePlugin(name);
361
+ const record = await ownHosting(plugin);
362
+ if (!record) {
363
+ throw httpError(`Plugin "${name}" is not hosted on an agent`, 404);
364
+ }
365
+ const push = await prepareHostingRemoval(plugin, record, { purge, sourceIp });
366
+ const { operation } = await startPluginOperation({
367
+ plugin: plugin.name,
368
+ kind: 'unhost',
369
+ actor,
370
+ params: { force, ...(purge ? { purge } : {}) },
371
+ logger,
372
+ run: (op) => teardownHosting(op, plugin, { force, purge, logger }),
373
+ });
374
+ await logAgentHalfPush(push, operation.id, logger);
375
+ return operation;
376
+ }
377
+
378
+ /**
379
+ * The uninstall pipeline for a plugin's hosting, inside an operation that
380
+ * is already running (unhost, or uninstalling a hosted plugin).
381
+ */
382
+ export async function teardownHosting(op, plugin, { force = false, purge = false, logger }) {
383
+ const config = getConfig();
384
+ const ctx = pipelineContext({
385
+ plugin,
386
+ agentLabel: null,
387
+ domain: config.domain,
388
+ email: config.email,
389
+ logger,
390
+ purge,
391
+ });
392
+ return runUninstallPipeline(op, ctx, { force });
393
+ }
394
+
395
+ /** Whether installed plugin `name` has a hosting of its own (in any phase). */
396
+ export async function isPluginHosted(name) {
397
+ return (await ownHostingOf(name)) !== null;
398
+ }
399
+
400
+ /**
401
+ * @param {string} name
402
+ * @returns {Promise<import('@lamalibre/lamaste/server').PluginHostingView | null>}
403
+ */
404
+ export async function getHostingView(name) {
405
+ const record = await ownHostingOf(name);
406
+ return record ? withLiveHealth(pluginHostingView(record)) : null;
407
+ }
408
+
409
+ /**
410
+ * `agentHosting` for every plugin of a list — null for plugins that are not
411
+ * hosted — and `retainedData`: what agents keep of the plugin's agent half
412
+ * after it was removed without purge.
413
+ */
414
+ export async function attachHostings(plugins) {
415
+ const store = await readPluginHostings(STATE_DIR);
416
+ const retained = await listRetainedData();
417
+ return plugins.map((p) => {
418
+ const record = p.route ? store[p.route] : undefined;
419
+ const own = record && pluginHostingOwner(record) === p.name ? record : null;
420
+ return {
421
+ ...p,
422
+ agentHosting: own ? withLiveHealth(pluginHostingView(own)) : null,
423
+ retainedData: retained.filter((r) => r.plugin === p.name),
424
+ };
425
+ });
426
+ }
427
+
428
+ /**
429
+ * @param {string} name
430
+ * @param {import('pino').Logger} logger
431
+ */
432
+ export async function rotateHostingSecret(name, logger) {
433
+ const record = await ownHostingOf(name);
434
+ if (!record) throw httpError(`Plugin "${name}" is not hosted on an agent`, 404);
435
+ try {
436
+ const rotated = await rotatePluginHostingSecret(STATE_DIR, record.plugin);
437
+ logger.info({ plugin: record.plugin }, 'Plugin installation secret rotated');
438
+ return pluginHostingView(rotated);
439
+ } catch (err) {
440
+ if (err instanceof PluginHostingError) throw httpError(err.message, 404);
441
+ throw err;
442
+ }
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Data agent halves left on agents
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Refuse (409 `retained_data_conflict`) installing agent package `pkg` for
451
+ * route `route` on agent `label` while that agent keeps the data of another
452
+ * agent package for the route — the agent's helper would refuse it too, and
453
+ * the data would otherwise go to code it does not belong to. Data of the
454
+ * same package is taken back by the install.
455
+ */
456
+ export async function assertNoForeignRetainedData(label, route, pkg) {
457
+ const retained = await findRetainedData(label, route);
458
+ if (retained && retained.package !== pkg) {
459
+ throw httpError(
460
+ `Agent "${label}" keeps the data of ${retained.package} (plugin "${retained.plugin}") for route "${route}" since ${retained.since} — Lamaste does not install ${pkg} over it; purge that data first: Purge under Retained data on the Plugins page, or lamaste-server plugins purge-data ${retained.plugin} --agent ${label}`,
461
+ 409,
462
+ { code: 'retained_data_conflict', retained },
463
+ );
464
+ }
465
+ }
466
+
467
+ /**
468
+ * What agents keep of plugin `name`'s agent half — by the name the plugin
469
+ * had, so it is found after the plugin is uninstalled too.
470
+ * @param {string} name
471
+ */
472
+ export function retainedDataOf(name) {
473
+ return listRetainedData({ plugin: name });
474
+ }
475
+
476
+ /**
477
+ * Purge the data agent `agentLabel` keeps of plugin `name`'s agent half —
478
+ * its data directory and service account — through the agent's helper (an
479
+ * `app-remove` job with purge, push-install gated as `uninstall`), as an
480
+ * operation. Works whether the plugin is still installed or not. `forget`
481
+ * only drops the relay's record and leaves the agent alone — for an agent
482
+ * that is gone, or data removed on the machine by hand; audited.
483
+ *
484
+ * @param {string} name the plugin the data belonged to
485
+ * @param {string} agentLabel
486
+ * @param {{ actor: string, logger: import('pino').Logger, sourceIp: string, forget?: boolean }} opts
487
+ * @returns {Promise<{ operation: object } | { forgotten: object }>}
488
+ */
489
+ export async function purgeRetainedData(name, agentLabel, { actor, logger, sourceIp, forget }) {
490
+ const retained = (await listRetainedData({ plugin: name, agentLabel }))[0] ?? null;
491
+ if (!retained) {
492
+ throw httpError(`Agent "${agentLabel}" keeps no data of plugin "${name}"`, 404);
493
+ }
494
+ const hosting = await ownHostingOf(name);
495
+ if (hosting && hosting.agentLabel === agentLabel) {
496
+ throw httpError(
497
+ `Plugin "${name}" is hosted on agent "${agentLabel}" and uses that data — remove its hosting with purge instead`,
498
+ 409,
499
+ { code: 'retained_data_in_use' },
500
+ );
501
+ }
502
+ if (forget) {
503
+ await forgetRetainedData(agentLabel, retained.route);
504
+ await recordAudit({
505
+ actor,
506
+ action: 'plugin.agent-half.retained-data.forgotten',
507
+ plugin: name,
508
+ details: { agentLabel, route: retained.route, package: retained.package },
509
+ });
510
+ return { forgotten: retained };
511
+ }
512
+ if (!(await isActiveAgent(agentLabel))) {
513
+ throw httpError(
514
+ `Agent "${agentLabel}" is no longer enrolled — nothing can be pushed to it. Purge the data on the machine (sudo lamaste-agent apphost remove ${retained.route} --purge), then forget the relay's record: Forget under Retained data on the Plugins page, or lamaste-server plugins forget-data ${name} --agent ${agentLabel}`,
515
+ 409,
516
+ { code: 'agent_not_enrolled' },
517
+ );
518
+ }
519
+ const push = await assertAgentHalfPush(agentLabel, sourceIp, 'uninstall', retained.package);
520
+ const { operation } = await startPluginOperation({
521
+ plugin: name,
522
+ kind: 'purge-data',
523
+ actor,
524
+ params: { agentLabel },
525
+ logger,
526
+ run: (op) => runPurgeRetainedData(op, { retained, logger }),
527
+ });
528
+ await logAgentHalfPush(push, operation.id, logger);
529
+ return { operation };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // The hosting agent's side
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * The `appPlugins` entries of the agent-config response for agent `label`,
538
+ * each with the tunnel-state hooks its agent half declares — the agent's
539
+ * sync calls them on its loopback when the relay's view of the tunnel
540
+ * changes (`health`).
541
+ */
542
+ export async function listAgentPluginAssignments(label) {
543
+ const { findPlugin } = await import('./plugins.js');
544
+ const store = await readPluginHostings(STATE_DIR);
545
+ const assignments = agentPluginAssignments(store, label);
546
+ return Promise.all(
547
+ assignments.map(async (a) => {
548
+ const owner = store[a.route] ? pluginHostingOwner(store[a.route]) : null;
549
+ const plugin = owner ? await findPlugin(owner) : null;
550
+ const stages = agentHookSetOf({ hooks: plugin?.hooks })?.stages ?? [];
551
+ return {
552
+ ...a,
553
+ agentHooks: stages
554
+ .filter((h) => h.stage === 'tunnel:down' || h.stage === 'tunnel:up')
555
+ .map((h) => ({ stage: h.stage, timeoutMs: h.timeoutMs })),
556
+ };
557
+ }),
558
+ );
559
+ }
560
+
561
+ /**
562
+ * The plugin config for the hosting agent `label` — port and secrets. Null
563
+ * when the plugin is not hosted on that agent.
564
+ * @param {string} label
565
+ * @param {string} route
566
+ */
567
+ export async function agentPluginConfig(label, route) {
568
+ const record = await getPluginHosting(STATE_DIR, route);
569
+ if (!record || record.agentLabel !== label) return null;
570
+ return agentPluginConfigFor(record);
571
+ }
572
+
573
+ /**
574
+ * The hosting agent confirms it holds the secret issued at `issuedAt`.
575
+ * @param {string} label
576
+ * @param {string} route
577
+ * @param {string} issuedAt
578
+ */
579
+ export async function acknowledgeAgentPluginConfig(label, route, issuedAt) {
580
+ const record = await getPluginHosting(STATE_DIR, route);
581
+ if (!record || record.agentLabel !== label) return null;
582
+ const { record: next, acknowledged } = await acknowledgePluginHostingSecret(
583
+ STATE_DIR,
584
+ route,
585
+ issuedAt,
586
+ );
587
+ return { acknowledged, secretIssuedAt: next.current.issuedAt };
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // The forwarder's side
592
+ // ---------------------------------------------------------------------------
593
+
594
+ /**
595
+ * Resolve a route to a forward target (see `agentForwarderPlugin`).
596
+ * @param {string} route
597
+ */
598
+ export async function resolveForwardTarget(route) {
599
+ const { findPluginByRoute } = await import('./plugins.js');
600
+ const plugin = await findPluginByRoute(route);
601
+ if (!plugin || !hasAgentHalf(plugin)) return { kind: 'not_found' };
602
+ if (plugin.status !== 'enabled') return { kind: 'disabled' };
603
+ const record = await ownHosting(plugin);
604
+ if (!record || pluginHostingPhase(record) !== 'active') return { kind: 'not_hosted' };
605
+ const tunnels = await readTunnels();
606
+ const tunnel = tunnels.find((t) => t.id === record.tunnelId);
607
+ const maxBodyMb = tunnel?.maxBodySizeMb ?? TUNNEL_MAX_BODY_MB.default;
608
+ return {
609
+ plugin: route,
610
+ port: record.port,
611
+ secret: signingSecretFor(record),
612
+ maxBodyBytes: maxBodyMb * 1024 * 1024,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Who the panel admits on a hosted plugin's routes: an administrator, or an
618
+ * agent holding one of the plugin's declared capabilities. The host
619
+ * capability is not one of them — hosting the application is not using it.
620
+ * @param {import('fastify').FastifyRequest} request
621
+ * @param {string} plugin
622
+ */
623
+ export async function authorizeForwardedCaller(request, plugin) {
624
+ if (request.certRole === 'admin') return { type: 'admin' };
625
+ if (request.certRole === 'agent' || request.certRole === 'plugin-agent') {
626
+ const prefix = `plugin:${plugin}:`;
627
+ const host = pluginHostCapability(plugin);
628
+ const caps = request.certCapabilities || [];
629
+ if (caps.some((c) => c.startsWith(prefix) && c !== host) && request.certLabel) {
630
+ return { type: 'agent', label: request.certLabel };
631
+ }
632
+ }
633
+ return null;
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // Health monitor
638
+ // ---------------------------------------------------------------------------
639
+
640
+ let monitor = null;
641
+
642
+ /** Start probing every hosting; idempotent. */
643
+ export function startHostedPluginHealthMonitor(logger) {
644
+ if (monitor) return monitor;
645
+ monitor = startPluginHealthMonitor({
646
+ listHostings: async () => {
647
+ const { readPlugins } = await import('./plugins.js');
648
+ const enabled = new Set(
649
+ (await readPlugins()).plugins.filter((p) => p.status === 'enabled').map((p) => p.name),
650
+ );
651
+ return Object.values(await readPluginHostings(STATE_DIR))
652
+ .filter((r) => enabled.has(pluginHostingOwner(r)) && pluginHostingPhase(r) === 'active')
653
+ .map((r) => ({
654
+ plugin: r.plugin,
655
+ port: r.port,
656
+ secret: signingSecretFor(r),
657
+ health: r.health,
658
+ }));
659
+ },
660
+ onTransition: async (plugin, health) => {
661
+ const before = (await getPluginHosting(STATE_DIR, plugin))?.health ?? null;
662
+ await updatePluginHostingHealth(STATE_DIR, plugin, health);
663
+ // The relay's tunnel:down / tunnel:up hooks, beside the monitor.
664
+ void onHealthTransition(plugin, before, health, logger);
665
+ },
666
+ logger,
667
+ });
668
+ return monitor;
669
+ }
670
+
671
+ export function stopHostedPluginHealthMonitor() {
672
+ monitor?.stop();
673
+ monitor = null;
674
+ }
675
+
676
+ /** Merge the monitor's last probe time into a persisted view. */
677
+ function withLiveHealth(view) {
678
+ const tracker = monitor?.snapshot().get(view.plugin);
679
+ if (!tracker) return view;
680
+ return {
681
+ ...view,
682
+ health: {
683
+ ...view.health,
684
+ ...(tracker.checkedAt ? { checkedAt: tracker.checkedAt } : {}),
685
+ ...(tracker.lastError && view.health.state !== 'up' ? { lastError: tracker.lastError } : {}),
686
+ },
687
+ };
688
+ }