@lamalibre/create-lamaste 4.3.0 → 4.4.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.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +13 -8
- package/src/index.js +4 -0
- package/src/lib/panel-pin.js +41 -0
- package/src/lib/service-config.js +6 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +50 -2
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +6 -6
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
- package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +7 -7
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
- package/vendor/docs/04-api-reference/app-host.md +87 -38
- package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
- package/vendor/docs/04-api-reference/certificates.md +22 -19
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -135
- package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +4 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +94 -84
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +2 -2
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +11 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/panel-pin.js +40 -0
- package/vendor/server/src/commands/plugin-settings.js +541 -0
- package/vendor/server/src/commands/plugins.js +94 -27
- package/vendor/server/src/panel-api.js +281 -34
- package/vendor/server/src/panel-identity.js +165 -0
- package/vendor/server/src/plugin-approval.js +78 -0
- package/vendor/server/src/plugin-settings.js +699 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +9 -0
- package/vendor/serverd/src/lib/agent-presence.js +106 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
- package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
- package/vendor/serverd/src/lib/mtls.js +44 -4
- package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
- package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
- package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
- package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
- package/vendor/serverd/src/lib/plugins.js +73 -22
- package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
- package/vendor/serverd/src/routes/management/plugins.js +166 -19
- package/vendor/serverd/src/routes/management/tunnels.js +21 -1
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
|
@@ -48,7 +48,12 @@ import {
|
|
|
48
48
|
import { httpError } from './app-error.js';
|
|
49
49
|
import { getConfig } from './config.js';
|
|
50
50
|
import { readTunnels } from './state.js';
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
getAgentHostFacts,
|
|
53
|
+
loadAgentRegistry,
|
|
54
|
+
saveAgentRegistry,
|
|
55
|
+
withRegistryLock,
|
|
56
|
+
} from './mtls.js';
|
|
52
57
|
import { startPluginOperation } from './plugin-operations.js';
|
|
53
58
|
import {
|
|
54
59
|
pipelineContext,
|
|
@@ -62,6 +67,8 @@ import { relaySigner } from './relay-signing.js';
|
|
|
62
67
|
import { findRetainedData, forgetRetainedData, listRetainedData } from './retained-data.js';
|
|
63
68
|
import { recordAudit } from './audit.js';
|
|
64
69
|
import { onHealthTransition } from './tunnel-hooks.js';
|
|
70
|
+
import { agentLastSeenAt, agentPresenceMap } from './agent-presence.js';
|
|
71
|
+
import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
|
|
65
72
|
|
|
66
73
|
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
67
74
|
|
|
@@ -145,12 +152,6 @@ export async function isActiveAgent(label) {
|
|
|
145
152
|
// Pushing an agent half: eligibility and the push-install gates
|
|
146
153
|
// ---------------------------------------------------------------------------
|
|
147
154
|
|
|
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
|
/**
|
|
155
156
|
* What this relay brings to eligibility: the id of the key it signs app
|
|
156
157
|
* jobs with — null when the key is unusable (it signs nothing then).
|
|
@@ -173,7 +174,7 @@ export async function assertAgentEligible(label, requires, agentHalf, pluginName
|
|
|
173
174
|
const { eligible, reasons } = hostEligibility(
|
|
174
175
|
requires,
|
|
175
176
|
agentHalf,
|
|
176
|
-
await
|
|
177
|
+
await getAgentHostFacts(label),
|
|
177
178
|
await relayEligibility(),
|
|
178
179
|
);
|
|
179
180
|
if (!eligible) {
|
|
@@ -336,7 +337,13 @@ export async function hostPlugin(name, agentLabel, { actor, logger, sourceIp })
|
|
|
336
337
|
actor,
|
|
337
338
|
params: { agentLabel },
|
|
338
339
|
logger,
|
|
339
|
-
run: (op) =>
|
|
340
|
+
run: async (op) => {
|
|
341
|
+
const result = await runInstallPipeline(op, ctx);
|
|
342
|
+
// The agent half starts without settings — or with those it kept on
|
|
343
|
+
// this agent from an earlier hosting: the relay's cache follows it.
|
|
344
|
+
if (result.status === 'succeeded') await refreshPluginSettingsBestEffort(plugin.name, logger);
|
|
345
|
+
return result;
|
|
346
|
+
},
|
|
340
347
|
});
|
|
341
348
|
await logAgentHalfPush(push, operation.id, logger);
|
|
342
349
|
return operation;
|
|
@@ -398,28 +405,42 @@ export async function isPluginHosted(name) {
|
|
|
398
405
|
}
|
|
399
406
|
|
|
400
407
|
/**
|
|
408
|
+
* The plugin's hosting as the panel shows it, with when its agent last
|
|
409
|
+
* synced (`agentLastSeenAt`, null when it never did since the panel knew).
|
|
410
|
+
*
|
|
401
411
|
* @param {string} name
|
|
402
|
-
* @returns {Promise<import('@lamalibre/lamaste/server').PluginHostingView | null>}
|
|
412
|
+
* @returns {Promise<(import('@lamalibre/lamaste/server').PluginHostingView & { agentLastSeenAt: string | null }) | null>}
|
|
403
413
|
*/
|
|
404
414
|
export async function getHostingView(name) {
|
|
405
415
|
const record = await ownHostingOf(name);
|
|
406
|
-
|
|
416
|
+
if (!record) return null;
|
|
417
|
+
return {
|
|
418
|
+
...withLiveHealth(pluginHostingView(record)),
|
|
419
|
+
agentLastSeenAt: await agentLastSeenAt(record.agentLabel),
|
|
420
|
+
};
|
|
407
421
|
}
|
|
408
422
|
|
|
409
423
|
/**
|
|
410
424
|
* `agentHosting` for every plugin of a list — null for plugins that are not
|
|
411
|
-
* hosted
|
|
412
|
-
* after it was
|
|
425
|
+
* hosted; with `agentLastSeenAt` as in {@link getHostingView} — and
|
|
426
|
+
* `retainedData`: what agents keep of the plugin's agent half after it was
|
|
427
|
+
* removed without purge.
|
|
413
428
|
*/
|
|
414
429
|
export async function attachHostings(plugins) {
|
|
415
430
|
const store = await readPluginHostings(STATE_DIR);
|
|
416
431
|
const retained = await listRetainedData();
|
|
432
|
+
const lastSeen = await agentPresenceMap();
|
|
417
433
|
return plugins.map((p) => {
|
|
418
434
|
const record = p.route ? store[p.route] : undefined;
|
|
419
435
|
const own = record && pluginHostingOwner(record) === p.name ? record : null;
|
|
420
436
|
return {
|
|
421
437
|
...p,
|
|
422
|
-
agentHosting: own
|
|
438
|
+
agentHosting: own
|
|
439
|
+
? {
|
|
440
|
+
...withLiveHealth(pluginHostingView(own)),
|
|
441
|
+
agentLastSeenAt: lastSeen.get(own.agentLabel) ?? null,
|
|
442
|
+
}
|
|
443
|
+
: null,
|
|
423
444
|
retainedData: retained.filter((r) => r.plugin === p.name),
|
|
424
445
|
};
|
|
425
446
|
});
|
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
* and the installed version stays live; then the install pipeline runs
|
|
13
13
|
* with `phase: "upgrade"` around installing it — the new agent half
|
|
14
14
|
* first, then the relay package — and a failure restores the previous
|
|
15
|
-
* version on both sides.
|
|
15
|
+
* version on both sides. Settings are outside the approval digest: the
|
|
16
|
+
* preview lists how the settings schema changes (`pendingUpdate.settings`)
|
|
17
|
+
* and the relay re-reads the agent half's settings after the update — but
|
|
18
|
+
* a version that turns a secret setting into a plain one always waits for
|
|
19
|
+
* an administrator's approval, its digest approved or not, and whatever
|
|
20
|
+
* auto-approve says (`pendingUpdate.approvalRequired` says why an update
|
|
21
|
+
* waits).
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
24
|
import {
|
|
@@ -24,6 +30,7 @@ import {
|
|
|
24
30
|
relayHookSetNeedsEntry,
|
|
25
31
|
relayHookSetOf,
|
|
26
32
|
relayHookSetPreview,
|
|
33
|
+
settingsSchemaDiff,
|
|
27
34
|
validateManifest,
|
|
28
35
|
} from '@lamalibre/lamaste';
|
|
29
36
|
import { pluginHostingPhase } from '@lamalibre/lamaste/server';
|
|
@@ -39,6 +46,9 @@ import {
|
|
|
39
46
|
} from './plugins.js';
|
|
40
47
|
import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
|
|
41
48
|
import {
|
|
49
|
+
DIGEST_UNAPPROVED,
|
|
50
|
+
SETTINGS_SECRET_TO_PLAIN,
|
|
51
|
+
digestUnapprovedReason,
|
|
42
52
|
installRunnerModule,
|
|
43
53
|
installedAgentHalf,
|
|
44
54
|
installedApproval,
|
|
@@ -71,6 +81,7 @@ import { pipelineContext, runUpgradePipeline } from './hook-pipelines.js';
|
|
|
71
81
|
import { getConfig } from './config.js';
|
|
72
82
|
import { recordAudit, recordAuditBestEffort } from './audit.js';
|
|
73
83
|
import { notifyAdminsBestEffort } from './notifications.js';
|
|
84
|
+
import { refreshPluginSettingsBestEffort, settingsBlockOf } from './plugin-settings.js';
|
|
74
85
|
|
|
75
86
|
/** Replaces the OIDC client sync (tests: there is no Authelia to write to). */
|
|
76
87
|
let oidcSyncOverride = null;
|
|
@@ -181,25 +192,124 @@ export async function uninstallPluginWithHosting(
|
|
|
181
192
|
* @returns {string[]}
|
|
182
193
|
*/
|
|
183
194
|
export function autoApproveWithheld(before, after) {
|
|
195
|
+
return agentHalfWithheld(before, after).map((reason) => reason.message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* {@link autoApproveWithheld}, each reason with its stable code (what the
|
|
200
|
+
* audit entry records beside the sentence): `agent-half.added`,
|
|
201
|
+
* `agent-half.package`, `agent-half.system-packages`, `agent-half.entry`,
|
|
202
|
+
* `agent-half.memory-max`.
|
|
203
|
+
*
|
|
204
|
+
* @returns {Array<{ code: string, message: string }>}
|
|
205
|
+
*/
|
|
206
|
+
function agentHalfWithheld(before, after) {
|
|
184
207
|
if (!after) return [];
|
|
185
|
-
if (!before)
|
|
208
|
+
if (!before) {
|
|
209
|
+
return [{ code: 'agent-half.added', message: 'it brings an agent half Lamaste installs' }];
|
|
210
|
+
}
|
|
186
211
|
const reasons = [];
|
|
187
212
|
if (after.package !== before.package) {
|
|
188
|
-
reasons.push(
|
|
213
|
+
reasons.push({
|
|
214
|
+
code: 'agent-half.package',
|
|
215
|
+
message: `its agent package changes (${before.package} → ${after.package})`,
|
|
216
|
+
});
|
|
189
217
|
}
|
|
190
218
|
const added = after.systemPackages.filter((p) => !before.systemPackages.includes(p));
|
|
191
|
-
if (added.length > 0)
|
|
219
|
+
if (added.length > 0) {
|
|
220
|
+
reasons.push({
|
|
221
|
+
code: 'agent-half.system-packages',
|
|
222
|
+
message: `it adds system packages (${added.join(', ')})`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
192
225
|
if (after.service.entry !== before.service.entry) {
|
|
193
|
-
reasons.push(
|
|
226
|
+
reasons.push({
|
|
227
|
+
code: 'agent-half.entry',
|
|
228
|
+
message: `its service entry changes (${before.service.entry} → ${after.service.entry})`,
|
|
229
|
+
});
|
|
194
230
|
}
|
|
195
231
|
if (after.service.memoryMax !== before.service.memoryMax) {
|
|
196
|
-
reasons.push(
|
|
197
|
-
|
|
198
|
-
|
|
232
|
+
reasons.push({
|
|
233
|
+
code: 'agent-half.memory-max',
|
|
234
|
+
message: `its service memory limit changes (${before.service.memoryMax ?? 'none'} → ${after.service.memoryMax ?? 'none'})`,
|
|
235
|
+
});
|
|
199
236
|
}
|
|
200
237
|
return reasons;
|
|
201
238
|
}
|
|
202
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Why a version waits for an administrator because of how it changes the
|
|
242
|
+
* settings schema: fields that are secrets now and plain values after it
|
|
243
|
+
* (`settings.secret-to-plain`). Their secrets are dropped on both sides,
|
|
244
|
+
* never moved — but whatever an administrator types into them next is kept
|
|
245
|
+
* on the relay and shown in clear, which a plugin release must not decide
|
|
246
|
+
* on its own. Settings are outside the approval digest, so this is a gate
|
|
247
|
+
* of its own: such an update waits whether or not its digest is approved,
|
|
248
|
+
* and auto-approve never covers it.
|
|
249
|
+
*
|
|
250
|
+
* @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} diff
|
|
251
|
+
* @returns {Array<{ code: string, reason: string }>}
|
|
252
|
+
*/
|
|
253
|
+
export function settingsApprovalRequired(diff) {
|
|
254
|
+
const keys = diff?.secretToPlain ?? [];
|
|
255
|
+
if (keys.length === 0) return [];
|
|
256
|
+
return [
|
|
257
|
+
{
|
|
258
|
+
code: SETTINGS_SECRET_TO_PLAIN,
|
|
259
|
+
reason: `it turns secret settings into plain ones (${keys.join(', ')}) — what is typed into them next is kept on the relay and shown in clear`,
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Why a staged version waits for an administrator's approval (`[{ code,
|
|
266
|
+
* reason }]`; empty: it needs none and runs at once) — what the approval of
|
|
267
|
+
* the pending update covers. Independent of auto-approve, which may stand
|
|
268
|
+
* in for an administrator on the first reason alone:
|
|
269
|
+
*
|
|
270
|
+
* - `digest.unapproved` — what it brings (relay hooks, the agent half
|
|
271
|
+
* Lamaste installs) is not what was approved;
|
|
272
|
+
* - `settings.secret-to-plain` — {@link settingsApprovalRequired}.
|
|
273
|
+
*
|
|
274
|
+
* @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null }} staged
|
|
275
|
+
* @param {{ approvedDigest: string | null } | null} state
|
|
276
|
+
* @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} settings
|
|
277
|
+
* @returns {Array<{ code: string, reason: string }>}
|
|
278
|
+
*/
|
|
279
|
+
function approvalRequiredFor(staged, state, settings) {
|
|
280
|
+
const digestApproved = !staged.approvalRequired || state?.approvedDigest === staged.digest;
|
|
281
|
+
return [
|
|
282
|
+
...(digestApproved
|
|
283
|
+
? []
|
|
284
|
+
: [digestUnapprovedReason({ relay: staged.relaySet, agentHalf: staged.agentHalf })]),
|
|
285
|
+
...settingsApprovalRequired(settings),
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check that what an administrator approved (`approvedUpdate`: the pending
|
|
291
|
+
* update as it was shown — its version, its digest, the secret settings it
|
|
292
|
+
* turns plain) covers the version as it was fetched again: the same
|
|
293
|
+
* version and digest, and no secret turned plain that was not approved.
|
|
294
|
+
*/
|
|
295
|
+
function assertApprovalCovers(staged, settings, approvedUpdate) {
|
|
296
|
+
if (approvedUpdate.version !== staged.version || approvedUpdate.digest !== staged.digest) {
|
|
297
|
+
throw httpError(
|
|
298
|
+
`Version ${staged.version} no longer brings what was approved — review it again`,
|
|
299
|
+
409,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const unapproved = (settings?.secretToPlain ?? []).filter(
|
|
303
|
+
(key) => !approvedUpdate.secretToPlain.includes(key),
|
|
304
|
+
);
|
|
305
|
+
if (unapproved.length > 0) {
|
|
306
|
+
throw httpError(
|
|
307
|
+
`Version ${staged.version} now turns secret settings into plain ones that were not approved (${unapproved.join(', ')}) — review it again`,
|
|
308
|
+
409,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
203
313
|
/**
|
|
204
314
|
* What a staged version brings, read without installing it: its manifest,
|
|
205
315
|
* its relay hook set and module, the agent half pinned for it, and the
|
|
@@ -290,6 +400,7 @@ async function stageVersion(plugin, requestedVersion, record) {
|
|
|
290
400
|
hooksEntrySha256: entrySha256,
|
|
291
401
|
agent: manifest.agent,
|
|
292
402
|
agentHalfPin,
|
|
403
|
+
settings: manifest.settings,
|
|
293
404
|
};
|
|
294
405
|
const approval = installedApproval(entry);
|
|
295
406
|
return {
|
|
@@ -309,14 +420,37 @@ async function stageVersion(plugin, requestedVersion, record) {
|
|
|
309
420
|
};
|
|
310
421
|
}
|
|
311
422
|
|
|
423
|
+
/**
|
|
424
|
+
* How a staged version changes the settings schema, for the update's
|
|
425
|
+
* preview (core's typed `settingsSchemaDiff`): fields added and removed
|
|
426
|
+
* with their types, retyped ones `from` → `to` (a retyped field's value is
|
|
427
|
+
* dropped on both sides, never converted), the fields the plugin will need
|
|
428
|
+
* filled, and the secrets that become plain values (`secretToPlain`, which
|
|
429
|
+
* makes the update wait for an administrator) — null when neither version
|
|
430
|
+
* declares settings.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} plugin the installed registry entry
|
|
433
|
+
* @param {object} manifest the staged version's manifest
|
|
434
|
+
*/
|
|
435
|
+
function settingsPreview(plugin, manifest) {
|
|
436
|
+
const installed = settingsBlockOf(plugin);
|
|
437
|
+
const staged = settingsBlockOf(manifest);
|
|
438
|
+
if (!installed && !staged) return null;
|
|
439
|
+
return settingsSchemaDiff(installed, staged);
|
|
440
|
+
}
|
|
441
|
+
|
|
312
442
|
/**
|
|
313
443
|
* Update plugin `name` to `requestedVersion` (an exact version or a
|
|
314
444
|
* dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
|
|
315
|
-
* (
|
|
445
|
+
* (it waits for an administrator's approval — shaped as the hook status's
|
|
316
446
|
* `pendingUpdate`) or `{ operation }`.
|
|
317
447
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
448
|
+
* An update waits when what it brings is not what was approved — unless
|
|
449
|
+
* auto-approve covers it — and whenever it turns a secret setting into a
|
|
450
|
+
* plain one (see {@link approvalRequiredFor}). `approvedUpdate` is set when
|
|
451
|
+
* an administrator approves the pending update (`{ version, digest,
|
|
452
|
+
* secretToPlain }`, as it was shown): the version is fetched again and must
|
|
453
|
+
* still bring exactly that digest, and turn no other secret plain.
|
|
320
454
|
* A hosted plugin whose agent half Lamaste installs is updated on its agent
|
|
321
455
|
* too: the agent must be able to host the new version and the push-install
|
|
322
456
|
* gates must allow `update` (`sourceIp`: the admin's) — checked when the
|
|
@@ -325,7 +459,7 @@ async function stageVersion(plugin, requestedVersion, record) {
|
|
|
325
459
|
export async function updatePlugin(
|
|
326
460
|
name,
|
|
327
461
|
requestedVersion,
|
|
328
|
-
{ actor, certSerial, logger,
|
|
462
|
+
{ actor, certSerial, logger, approvedUpdate = null, sourceIp },
|
|
329
463
|
) {
|
|
330
464
|
const plugin = await findPlugin(name);
|
|
331
465
|
if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
|
|
@@ -354,77 +488,43 @@ export async function updatePlugin(
|
|
|
354
488
|
}
|
|
355
489
|
|
|
356
490
|
const state = await readHookState(name);
|
|
491
|
+
const settings = settingsPreview(plugin, staged.manifest);
|
|
492
|
+
const approvalRequired = approvalRequiredFor(staged, state, settings);
|
|
357
493
|
let approvedBy = null;
|
|
358
494
|
let autoApproved = false;
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
agentHalf: staged.agentHalf
|
|
388
|
-
? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
|
|
389
|
-
: null,
|
|
390
|
-
...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
|
|
391
|
-
};
|
|
392
|
-
await setPendingUpdate(name, pendingUpdate);
|
|
393
|
-
if (withheld.length > 0) {
|
|
394
|
-
await recordAudit({
|
|
395
|
-
actor: 'auto-approve',
|
|
396
|
-
action: 'hooks.auto-approve.withheld',
|
|
397
|
-
plugin: name,
|
|
398
|
-
details: {
|
|
399
|
-
digest: staged.digest,
|
|
400
|
-
version: staged.version,
|
|
401
|
-
reasons: withheld,
|
|
402
|
-
requestedBy: actor,
|
|
403
|
-
},
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
await recordAudit({
|
|
407
|
-
actor,
|
|
408
|
-
action: 'plugin.update.awaiting-approval',
|
|
409
|
-
plugin: name,
|
|
410
|
-
details: { from: plugin.version, to: staged.version, digest: staged.digest, certSerial },
|
|
411
|
-
});
|
|
412
|
-
await notifyAdminsBestEffort(
|
|
413
|
-
{
|
|
414
|
-
source: 'lamaste',
|
|
415
|
-
level: 'warning',
|
|
416
|
-
message:
|
|
417
|
-
withheld.length > 0
|
|
418
|
-
? `${name} ${staged.version} waits for your approval — auto-approve does not cover it: ${withheld.join('; ')}.`
|
|
419
|
-
: staged.agentHalf
|
|
420
|
-
? `${name} ${staged.version} brings a new agent half${staged.relaySet ? ' and relay hooks' : ''} — it waits for your approval.`
|
|
421
|
-
: `${name} ${staged.version} brings a changed relay hook set — it waits for your approval.`,
|
|
422
|
-
},
|
|
423
|
-
logger,
|
|
424
|
-
);
|
|
425
|
-
return { pendingUpdate: pendingUpdateView(pendingUpdate) };
|
|
426
|
-
}
|
|
495
|
+
if (approvedUpdate !== null) {
|
|
496
|
+
assertApprovalCovers(staged, settings, approvedUpdate);
|
|
497
|
+
approvedBy = actor;
|
|
498
|
+
} else if (approvalRequired.length > 0) {
|
|
499
|
+
// Auto-approve stands in for an administrator on a digest that changed,
|
|
500
|
+
// when the agent half changes nothing that runs as root on the agent —
|
|
501
|
+
// and on nothing else: every other reason (a secret setting turned
|
|
502
|
+
// plain) withholds it.
|
|
503
|
+
const digestChanged = approvalRequired.some((reason) => reason.code === DIGEST_UNAPPROVED);
|
|
504
|
+
const withheldReasons = state?.autoApprove
|
|
505
|
+
? [
|
|
506
|
+
...(digestChanged ? agentHalfWithheld(installedAgentHalf(plugin), staged.agentHalf) : []),
|
|
507
|
+
...approvalRequired
|
|
508
|
+
.filter((reason) => reason.code !== DIGEST_UNAPPROVED)
|
|
509
|
+
.map(({ code, reason }) => ({ code, message: reason })),
|
|
510
|
+
]
|
|
511
|
+
: [];
|
|
512
|
+
if (!state?.autoApprove || withheldReasons.length > 0) {
|
|
513
|
+
return awaitApproval({
|
|
514
|
+
plugin,
|
|
515
|
+
staged,
|
|
516
|
+
settings,
|
|
517
|
+
approvalRequired,
|
|
518
|
+
withheldReasons,
|
|
519
|
+
actor,
|
|
520
|
+
certSerial,
|
|
521
|
+
logger,
|
|
522
|
+
});
|
|
427
523
|
}
|
|
524
|
+
approvedBy = 'auto-approve';
|
|
525
|
+
autoApproved = true;
|
|
526
|
+
} else if (staged.approvalRequired) {
|
|
527
|
+
approvedBy = state.approvedBy ?? 'unchanged';
|
|
428
528
|
}
|
|
429
529
|
|
|
430
530
|
// The agent half goes onto the hosting agent: a push the agent must be
|
|
@@ -451,16 +551,19 @@ export async function updatePlugin(
|
|
|
451
551
|
staged.agentHalf.package,
|
|
452
552
|
);
|
|
453
553
|
}
|
|
454
|
-
|
|
554
|
+
// The approval takes effect now that the update starts under it: what it
|
|
555
|
+
// covered (`codes`), by an administrator or by auto-approve.
|
|
556
|
+
if (autoApproved || approvedUpdate !== null) {
|
|
455
557
|
await recordAudit({
|
|
456
|
-
actor: 'auto-approve',
|
|
558
|
+
actor: autoApproved ? 'auto-approve' : actor,
|
|
457
559
|
action: 'hooks.approved',
|
|
458
560
|
plugin: name,
|
|
459
561
|
details: {
|
|
460
562
|
digest: staged.digest,
|
|
461
563
|
version: staged.version,
|
|
462
564
|
for: 'update',
|
|
463
|
-
|
|
565
|
+
codes: approvalRequired.map((reason) => reason.code),
|
|
566
|
+
...(autoApproved ? { requestedBy: actor } : { certSerial }),
|
|
464
567
|
},
|
|
465
568
|
});
|
|
466
569
|
}
|
|
@@ -477,6 +580,83 @@ export async function updatePlugin(
|
|
|
477
580
|
return { operation };
|
|
478
581
|
}
|
|
479
582
|
|
|
583
|
+
/**
|
|
584
|
+
* Stage `staged` as the plugin's pending update, waiting for an
|
|
585
|
+
* administrator: stored with why it waits (`approvalRequired`) and why
|
|
586
|
+
* auto-approve, when on, did not cover it (`withheldReasons`), audited and
|
|
587
|
+
* notified. The installed version stays live.
|
|
588
|
+
*
|
|
589
|
+
* @returns {Promise<{ pendingUpdate: object }>}
|
|
590
|
+
*/
|
|
591
|
+
async function awaitApproval({
|
|
592
|
+
plugin,
|
|
593
|
+
staged,
|
|
594
|
+
settings,
|
|
595
|
+
approvalRequired,
|
|
596
|
+
withheldReasons,
|
|
597
|
+
actor,
|
|
598
|
+
certSerial,
|
|
599
|
+
logger,
|
|
600
|
+
}) {
|
|
601
|
+
const name = plugin.name;
|
|
602
|
+
const withheld = withheldReasons.map((reason) => reason.message);
|
|
603
|
+
const pendingUpdate = {
|
|
604
|
+
version: staged.version,
|
|
605
|
+
digest: staged.digest,
|
|
606
|
+
stagedAt: new Date().toISOString(),
|
|
607
|
+
preview: staged.relaySet ? relayHookSetPreview(staged.relaySet, staged.entrySha256) : null,
|
|
608
|
+
agentHalf: staged.agentHalf
|
|
609
|
+
? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
|
|
610
|
+
: null,
|
|
611
|
+
settings,
|
|
612
|
+
approvalRequired,
|
|
613
|
+
...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
|
|
614
|
+
};
|
|
615
|
+
await setPendingUpdate(name, pendingUpdate);
|
|
616
|
+
if (withheld.length > 0) {
|
|
617
|
+
await recordAudit({
|
|
618
|
+
actor: 'auto-approve',
|
|
619
|
+
action: 'hooks.auto-approve.withheld',
|
|
620
|
+
plugin: name,
|
|
621
|
+
details: {
|
|
622
|
+
digest: staged.digest,
|
|
623
|
+
version: staged.version,
|
|
624
|
+
reasons: withheld,
|
|
625
|
+
codes: withheldReasons.map((reason) => reason.code),
|
|
626
|
+
requestedBy: actor,
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
await recordAudit({
|
|
631
|
+
actor,
|
|
632
|
+
action: 'plugin.update.awaiting-approval',
|
|
633
|
+
plugin: name,
|
|
634
|
+
details: {
|
|
635
|
+
from: plugin.version,
|
|
636
|
+
to: staged.version,
|
|
637
|
+
digest: staged.digest,
|
|
638
|
+
codes: approvalRequired.map((reason) => reason.code),
|
|
639
|
+
certSerial,
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
// What auto-approve withheld beyond the reasons themselves (an agent half
|
|
643
|
+
// that runs more as root); a secret turned plain is one of the reasons.
|
|
644
|
+
const notCovered = withheldReasons
|
|
645
|
+
.filter((withheldReason) => !approvalRequired.some((r) => r.code === withheldReason.code))
|
|
646
|
+
.map((reason) => reason.message);
|
|
647
|
+
await notifyAdminsBestEffort(
|
|
648
|
+
{
|
|
649
|
+
source: 'lamaste',
|
|
650
|
+
level: 'warning',
|
|
651
|
+
message:
|
|
652
|
+
`${name} ${staged.version} waits for your approval: ${approvalRequired.map((r) => r.reason).join('; ')}.` +
|
|
653
|
+
(notCovered.length > 0 ? ` Auto-approve does not cover it: ${notCovered.join('; ')}.` : ''),
|
|
654
|
+
},
|
|
655
|
+
logger,
|
|
656
|
+
);
|
|
657
|
+
return { pendingUpdate: pendingUpdateView(pendingUpdate) };
|
|
658
|
+
}
|
|
659
|
+
|
|
480
660
|
async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
|
|
481
661
|
const name = plugin.name;
|
|
482
662
|
const oldRelay = installedRelayHooks(plugin);
|
|
@@ -577,6 +757,10 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
|
|
|
577
757
|
|
|
578
758
|
if (result.status === 'succeeded') op.revealOidcClientSecrets(createdSecrets);
|
|
579
759
|
|
|
760
|
+
// The agent half may have dropped values the new version retyped or no
|
|
761
|
+
// longer knows (and keeps that after a revert): the relay re-reads them.
|
|
762
|
+
if (hosted) await refreshPluginSettingsBestEffort(name, logger);
|
|
763
|
+
|
|
580
764
|
// Hooks the new version no longer has: the old runner goes once the new
|
|
581
765
|
// version is in place.
|
|
582
766
|
if (result.status === 'succeeded' && oldRelay && !staged.relaySet) {
|