@lamalibre/create-lamaste 4.3.1 → 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.
Files changed (72) hide show
  1. package/package.json +2 -2
  2. package/src/lib/service-config.js +6 -0
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +38 -1
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +6 -6
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +11 -11
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  19. package/vendor/docs/03-architecture/installer.md +4 -4
  20. package/vendor/docs/03-architecture/management-flow.md +1 -1
  21. package/vendor/docs/03-architecture/overview.md +5 -5
  22. package/vendor/docs/03-architecture/panel-client.md +7 -7
  23. package/vendor/docs/03-architecture/panel-server.md +2 -2
  24. package/vendor/docs/03-architecture/state-management.md +2 -2
  25. package/vendor/docs/03-architecture/system-overview.md +1 -1
  26. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  27. package/vendor/docs/04-api-reference/app-host.md +87 -38
  28. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  29. package/vendor/docs/04-api-reference/certificates.md +22 -19
  30. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  31. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  32. package/vendor/docs/04-api-reference/overview.md +137 -135
  33. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  34. package/vendor/docs/05-operations/backup-and-restore.md +2 -1
  35. package/vendor/docs/05-operations/uninstalling.md +1 -1
  36. package/vendor/docs/05-operations/upgrades.md +6 -6
  37. package/vendor/docs/06-reference/config-files.md +89 -84
  38. package/vendor/docs/06-reference/glossary.md +3 -3
  39. package/vendor/docs/06-reference/installer-flags.md +13 -13
  40. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  41. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  42. package/vendor/docs/gatekeeper-design.md +8 -8
  43. package/vendor/docs/identity-directory-design.md +2 -2
  44. package/vendor/docs/package.json +1 -1
  45. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  46. package/vendor/gatekeeper/package.json +2 -2
  47. package/vendor/server/bin/lamaste-server.js +4 -1
  48. package/vendor/server/package.json +4 -3
  49. package/vendor/server/src/commands/plugin-settings.js +541 -0
  50. package/vendor/server/src/commands/plugins.js +94 -27
  51. package/vendor/server/src/panel-api.js +36 -4
  52. package/vendor/server/src/plugin-approval.js +78 -0
  53. package/vendor/server/src/plugin-settings.js +699 -0
  54. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  55. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  56. package/vendor/server-ui/dist/index.html +2 -2
  57. package/vendor/serverd/package.json +2 -2
  58. package/vendor/serverd/src/index.js +9 -0
  59. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  60. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  61. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  62. package/vendor/serverd/src/lib/mtls.js +44 -4
  63. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  64. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  65. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  66. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  67. package/vendor/serverd/src/lib/plugins.js +73 -22
  68. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  69. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  70. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  71. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
  72. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Lamaste</title>
7
- <script type="module" crossorigin src="/assets/index-BTlBt_cD.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Bc5NCMSv.css">
7
+ <script type="module" crossorigin src="/assets/index-CESyyK8V.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-25wo_8av.css">
9
9
  </head>
10
10
  <body style="background-color: #09090b">
11
11
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-serverd",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
4
4
  "description": "Lamaste server daemon — REST API and plugin host",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@
29
29
  "@fastify/rate-limit": "^10.3.0",
30
30
  "@fastify/static": "^9.1.1",
31
31
  "@fastify/websocket": "^11.0.0",
32
- "@lamalibre/lamaste": "^4.3.1",
32
+ "@lamalibre/lamaste": "^4.4.0",
33
33
  "bcryptjs": "^3.0.3",
34
34
  "execa": "^9.6.1",
35
35
  "fastify": "^5.8.4",
@@ -37,6 +37,7 @@ import {
37
37
  startHostedPluginHealthMonitor,
38
38
  stopHostedPluginHealthMonitor,
39
39
  } from './lib/plugin-hosting.js';
40
+ import { refreshHostedPluginSettings } from './lib/plugin-settings.js';
40
41
  import { syncOidcClients } from './lib/plugin-oidc.js';
41
42
  import { ensureRelaySigningKey } from './lib/relay-signing.js';
42
43
  import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
@@ -228,6 +229,14 @@ async function start() {
228
229
 
229
230
  // --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
230
231
  startHostedPluginHealthMonitor(server.log);
232
+ // The monitor resumes from the persisted health, so a plugin that stayed
233
+ // up across the restart sees no transition that would re-read its
234
+ // settings: read them once now — one plugin after another, best effort —
235
+ // so a change made while the panel was down is seen (and, when Lamaste
236
+ // did not make it, reported).
237
+ refreshHostedPluginSettings(server.log).catch((err) => {
238
+ server.log.warn({ err: err.message }, 'Startup refresh of plugin settings failed');
239
+ });
231
240
 
232
241
  // --- Periodic ticket instance liveness check (every 60s) ---
233
242
  const livenessInterval = setInterval(() => {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * When each machine agent last contacted the relay — the hosting-agent
3
+ * state the panel shows next to a hosted plugin ("hosting agent offline
4
+ * since … — last sync …").
5
+ *
6
+ * An agent's sync timer calls `GET /api/tunnels/agent-config` every 30 s;
7
+ * that call records the agent here. The time is kept in memory and written
8
+ * to `agent_presence` at most once per {@link PRESENCE_PERSIST_INTERVAL_MS}
9
+ * per agent, so a restart of the panel loses at most that much — and the
10
+ * `agents` table, which the registry rewrites whole, is never touched.
11
+ */
12
+
13
+ import { getStateDb } from './state-db.js';
14
+
15
+ /** An agent's contact is written to state.db at most this often. */
16
+ export const PRESENCE_PERSIST_INTERVAL_MS = 60_000;
17
+
18
+ /** label → { seenAt, persistedAt } (epoch milliseconds); filled from the table once. */
19
+ const presence = new Map();
20
+ let loaded = false;
21
+ let clock = () => Date.now();
22
+
23
+ /** Replace the clock (tests); null restores `Date.now`. */
24
+ export function setPresenceClockForTests(fn) {
25
+ clock = fn ?? (() => Date.now());
26
+ }
27
+
28
+ /** Forget what memory holds, as a restart of the panel would (tests). */
29
+ export function forgetPresenceMemoryForTests() {
30
+ presence.clear();
31
+ loaded = false;
32
+ }
33
+
34
+ function loadOnce(db) {
35
+ if (loaded) return;
36
+ for (const row of db.prepare('SELECT label, last_seen_at FROM agent_presence').all()) {
37
+ const at = Date.parse(row.last_seen_at);
38
+ if (Number.isFinite(at) && !presence.has(row.label)) {
39
+ presence.set(row.label, { seenAt: at, persistedAt: at });
40
+ }
41
+ }
42
+ loaded = true;
43
+ }
44
+
45
+ /**
46
+ * Agent `label` contacted the relay now. Returns whether the contact was
47
+ * written to state.db (false while the last write is under a minute old).
48
+ *
49
+ * @param {string} label
50
+ * @returns {Promise<{ persisted: boolean }>}
51
+ */
52
+ export async function recordAgentContact(label) {
53
+ const db = await getStateDb();
54
+ loadOnce(db);
55
+ const now = clock();
56
+ const entry = presence.get(label);
57
+ if (
58
+ entry &&
59
+ entry.persistedAt !== null &&
60
+ now >= entry.persistedAt &&
61
+ now - entry.persistedAt < PRESENCE_PERSIST_INTERVAL_MS
62
+ ) {
63
+ entry.seenAt = Math.max(entry.seenAt, now);
64
+ return { persisted: false };
65
+ }
66
+ db.prepare(
67
+ `INSERT INTO agent_presence (label, last_seen_at) VALUES (?, ?)
68
+ ON CONFLICT(label) DO UPDATE SET last_seen_at = excluded.last_seen_at`,
69
+ ).run(label, new Date(now).toISOString());
70
+ presence.set(label, { seenAt: now, persistedAt: now });
71
+ return { persisted: true };
72
+ }
73
+
74
+ /**
75
+ * When each agent last contacted the relay (ISO 8601), by label. Agents
76
+ * that never did are absent.
77
+ *
78
+ * @returns {Promise<Map<string, string>>}
79
+ */
80
+ export async function agentPresenceMap() {
81
+ const db = await getStateDb();
82
+ loadOnce(db);
83
+ const out = new Map();
84
+ for (const [label, entry] of presence) out.set(label, new Date(entry.seenAt).toISOString());
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * When agent `label` last contacted the relay (ISO 8601), or null.
90
+ * @param {string} label
91
+ */
92
+ export async function agentLastSeenAt(label) {
93
+ return (await agentPresenceMap()).get(label) ?? null;
94
+ }
95
+
96
+ /**
97
+ * Drop what is known of agent `label` — its certificate was revoked, and a
98
+ * later enrollment reusing the label must not inherit its last contact.
99
+ * @param {string} label
100
+ */
101
+ export async function forgetAgentPresence(label) {
102
+ const db = await getStateDb();
103
+ loadOnce(db);
104
+ db.prepare('DELETE FROM agent_presence WHERE label = ?').run(label);
105
+ presence.delete(label);
106
+ }
@@ -40,6 +40,9 @@
40
40
  * plugin that owns it; a teardown acts only on the plugin's own.
41
41
  *
42
42
  * Hook outputs are passed forward in `outputs` (`relay:install:pre-tunnel`, …).
43
+ * Relay stages see the plugin's effective non-secret settings (the relay's
44
+ * cache, `plugin-settings.js`); agent stages see none — the agent half holds
45
+ * them. The cache goes once the hosting it describes is removed.
43
46
  */
44
47
 
45
48
  import {
@@ -79,6 +82,7 @@ import { installedAgentHalf, installedRelayHooks } from './plugin-hooks.js';
79
82
  import { forgetRetainedData, recordRetainedData } from './retained-data.js';
80
83
  import { runRelayHook } from './relay-hooks.js';
81
84
  import { recordAuditBestEffort } from './audit.js';
85
+ import { deleteSettingsCache, relayHookSettings } from './plugin-settings.js';
82
86
  import {
83
87
  tunnelNginxDeps,
84
88
  tunnelCertbotDeps,
@@ -97,15 +101,19 @@ const TUNNEL_READY_POLL_MS = 3_000;
97
101
  // Stages
98
102
  // ---------------------------------------------------------------------------
99
103
 
100
- /** The pipeline-wide context: who, what, and the outputs so far. */
101
- function lifecycleInput(ctx, phase) {
104
+ /**
105
+ * The pipeline-wide context: who, what, and the outputs so far. `settings`
106
+ * is the plugin's effective non-secret settings for a relay stage, `{}` for
107
+ * an agent stage.
108
+ */
109
+ function lifecycleInput(ctx, phase, settings = {}) {
102
110
  return {
103
111
  plugin: ctx.plugin.name,
104
112
  version: ctx.version ?? ctx.plugin.version,
105
113
  ...(ctx.previousVersion ? { previousVersion: ctx.previousVersion } : {}),
106
114
  phase,
107
115
  agent: { label: ctx.agentLabel },
108
- settings: {},
116
+ settings,
109
117
  outputs: { ...ctx.outputs },
110
118
  ...(ctx.port ? { port: ctx.port } : {}),
111
119
  };
@@ -132,7 +140,7 @@ async function relayStage(op, ctx, stage, phase) {
132
140
  plugin: ctx.plugin.name,
133
141
  stage,
134
142
  call: { kind: 'app' },
135
- input: lifecycleInput(ctx, phase),
143
+ input: lifecycleInput(ctx, phase, await relayHookSettings(ctx.plugin)),
136
144
  timeoutMs: decl.timeoutMs,
137
145
  effects: ctx.relay.set.effects,
138
146
  logger: ctx.logger,
@@ -821,6 +829,14 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
821
829
  : null;
822
830
  await runTeardown(op, ctx, { phase: 'uninstall', force });
823
831
  await cancelAgentJobs(ctx.route).catch(() => undefined);
832
+ // The relay's cache of the settings describes the hosting's agent half:
833
+ // with the hosting gone it describes nothing (a later hosting reads them
834
+ // afresh — from data the agent kept, or none).
835
+ if (!(await getPluginHosting(STATE_DIR, ctx.route))) {
836
+ await deleteSettingsCache(ctx.plugin.name).catch((err) =>
837
+ op.warn(`Could not drop the cached settings: ${err.message}`),
838
+ );
839
+ }
824
840
  await recordAuditBestEffort(
825
841
  {
826
842
  actor: op.actor ?? 'admin',
@@ -0,0 +1,62 @@
1
+ -- Plugin settings and the presence of hosting agents (4.3, slice 4).
2
+
3
+ -- The manifest's `settings` block (JSON; NULL when the plugin declares
4
+ -- none): the schema the panel checks an administrator's change against
5
+ -- before sending it to the agent half, and the keys it keeps in the cache
6
+ -- below. Settings are not part of the approval digest.
7
+ ALTER TABLE plugins ADD COLUMN settings TEXT;
8
+
9
+ -- The relay's cache of each plugin's settings. The agent half is the system
10
+ -- of record (settings.json and secrets.json in its data directory); this row
11
+ -- is refreshed from its `GET /admin/settings` after hosting, after an
12
+ -- update, when the health monitor sees the plugin come up, and from its
13
+ -- answer to every change — so the panel can show the settings, and whether
14
+ -- the plugin still needs configuration, while the agent is offline.
15
+ -- There is no column for a secret's value: a secret exists on the relay only
16
+ -- in the memory of the request that carries it to the agent half.
17
+ -- schema_version the agent half's settings schema version
18
+ -- revision the agent half's revision (+1 per change)
19
+ -- values_json JSON {key: value} — the non-secret values the agent half
20
+ -- stored (no defaults), for keys the installed schema knows
21
+ -- secrets_set_json JSON {key: setAt} — which secrets are set, and when
22
+ -- configured 1 when no required field lacks a value
23
+ -- missing_json JSON [key] — the required fields without a value
24
+ -- synced_at when the relay last read the agent half's state
25
+ -- lamaste_revision the agent half's revision as Lamaste last wrote it (its
26
+ -- answer to a change) or adopted it; NULL until the first
27
+ -- read. A refresh that finds another revision found a
28
+ -- change made outside Lamaste: audited and notified (key
29
+ -- names only), then adopted.
30
+ -- unconfirmed_change_json
31
+ -- JSON {revision, actor, certSerial?, changed, cleared,
32
+ -- secretsSet, secretsCleared} — the key names of a change
33
+ -- whose answer never authenticated (a timeout, say), which
34
+ -- the agent half may have stored. A refresh that finds
35
+ -- exactly that revision, changed in those keys only,
36
+ -- records the change as Lamaste's.
37
+ -- When the installed settings schema changes (an update or its revert), the
38
+ -- values and secrets whose field changed type are pruned in the transaction
39
+ -- that writes the plugin's row.
40
+ -- Removed when the plugin's hosting is removed and when it is uninstalled.
41
+ CREATE TABLE plugin_settings (
42
+ plugin TEXT PRIMARY KEY,
43
+ schema_version INTEGER NOT NULL,
44
+ revision INTEGER NOT NULL,
45
+ values_json TEXT NOT NULL,
46
+ secrets_set_json TEXT NOT NULL,
47
+ configured INTEGER NOT NULL,
48
+ missing_json TEXT NOT NULL,
49
+ synced_at TEXT NOT NULL,
50
+ lamaste_revision INTEGER,
51
+ unconfirmed_change_json TEXT
52
+ );
53
+
54
+ -- When each machine agent last contacted the relay: its sync's
55
+ -- `GET /api/tunnels/agent-config`, every 30 s. Kept in memory and written
56
+ -- here at most once a minute per agent — never through the `agents` table,
57
+ -- which the registry rewrites whole. Tells how long a hosting agent has been
58
+ -- gone. Removed when the agent's certificate is revoked.
59
+ CREATE TABLE agent_presence (
60
+ label TEXT PRIMARY KEY,
61
+ last_seen_at TEXT NOT NULL
62
+ );
@@ -17,6 +17,7 @@ import {
17
17
  BASE_CAPABILITIES as CORE_BASE_CAPABILITIES,
18
18
  HostFactsSchema,
19
19
  PLUGIN_AGENT_CN_PREFIX as CORE_PLUGIN_AGENT_CN_PREFIX,
20
+ hostFactsHash,
20
21
  } from '@lamalibre/lamaste';
21
22
  import { addToRevocationList } from './revocation.js';
22
23
  import { releaseAgentTunnels, stripPluginCapabilities } from '@lamalibre/lamaste/server';
@@ -27,6 +28,7 @@ import {
27
28
  rotateChiselCredential as rotateChiselCredentialLib,
28
29
  } from './chisel-users.js';
29
30
  import { getStateDb } from './state-db.js';
31
+ import { agentPresenceMap, forgetAgentPresence } from './agent-presence.js';
30
32
  import { randomCertSerial, signAgentCsr } from './pki-sign.js';
31
33
 
32
34
  const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
@@ -577,14 +579,17 @@ export async function generateAgentCert(label, logger, capabilities, allowedSite
577
579
  }
578
580
 
579
581
  /**
580
- * List all agent certificates with expiry status and the host facts each
581
- * agent last reported. `status` sums a certificate up for the panel:
582
- * `revoked`, `expired`, `expiring-soon` (within 30 days) or `active`.
582
+ * List all agent certificates with expiry status, the host facts each
583
+ * agent last reported and when it last contacted the relay (`lastSeenAt`:
584
+ * its sync's agent-config call; null when it never did since it enrolled).
585
+ * `status` sums a certificate up for the panel: `revoked`, `expired`,
586
+ * `expiring-soon` (within 30 days) or `active`.
583
587
  *
584
- * @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean, status: 'active' | 'expiring-soon' | 'expired' | 'revoked', hostFacts: object | null, hostFactsAt: string | null }>>}
588
+ * @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean, status: 'active' | 'expiring-soon' | 'expired' | 'revoked', hostFacts: object | null, hostFactsAt: string | null, lastSeenAt: string | null }>>}
585
589
  */
586
590
  export async function listAgentCerts() {
587
591
  const registry = await loadAgentRegistry();
592
+ const lastSeen = await agentPresenceMap();
588
593
  const now = Date.now();
589
594
 
590
595
  return registry.agents.map((agent) => {
@@ -612,6 +617,7 @@ export async function listAgentCerts() {
612
617
  // What the agent last reported about its host (null until it has).
613
618
  hostFacts: agent.hostFacts ?? null,
614
619
  hostFactsAt: agent.hostFactsAt ?? null,
620
+ lastSeenAt: agent.revoked ? null : (lastSeen.get(agent.label) ?? null),
615
621
  };
616
622
  if (isPluginAgent) {
617
623
  entry.certType = 'plugin-agent';
@@ -738,6 +744,34 @@ export function setAgentHostFacts(label, hostFacts) {
738
744
  });
739
745
  }
740
746
 
747
+ /**
748
+ * The host facts agent `label` last reported, as stored on its enrolled,
749
+ * unrevoked record — null when it has none: it never reported, its record
750
+ * was replaced since (enrolled again on the label, a restored backup), or
751
+ * the stored facts no longer parse.
752
+ *
753
+ * @param {string} label
754
+ * @returns {Promise<import('@lamalibre/lamaste').HostFacts | null>}
755
+ */
756
+ export async function getAgentHostFacts(label) {
757
+ const registry = await loadAgentRegistry();
758
+ return registry.agents.find((a) => a.label === label && !a.revoked)?.hostFacts ?? null;
759
+ }
760
+
761
+ /**
762
+ * The hash of the host facts stored for agent `label` — core's
763
+ * `hostFactsHash`, exactly what the agent computes over its own — or null
764
+ * when it has none ({@link getAgentHostFacts}). The agent's sync gets it in
765
+ * agent-config and reports its facts again when it is not theirs.
766
+ *
767
+ * @param {string} label
768
+ * @returns {Promise<string | null>}
769
+ */
770
+ export async function getAgentHostFactsHash(label) {
771
+ const facts = await getAgentHostFacts(label);
772
+ return facts === null ? null : hostFactsHash(facts);
773
+ }
774
+
741
775
  /**
742
776
  * Revoke an agent certificate by label.
743
777
  *
@@ -793,6 +827,12 @@ export async function revokeAgentCert(label, logger) {
793
827
  // 4. Save registry atomically (includes both the agent and cascade-revoked plugin-agents)
794
828
  await saveAgentRegistry(registry);
795
829
 
830
+ // When the agent last synced: a later enrollment reusing the label
831
+ // starts without it.
832
+ await forgetAgentPresence(label).catch((err) => {
833
+ logger.warn({ err: err.message, label }, 'Failed to forget the agent presence');
834
+ });
835
+
796
836
  // 5. Remove agent's key/cert/p12 files (lamaste owns the agents directory)
797
837
  await execa('rm', ['-rf', `${AGENTS_DIR}/${label}/`]).catch((err) => {
798
838
  logger.warn({ err, label }, 'Failed to remove agent certificate files');
@@ -10,7 +10,9 @@
10
10
  * tarball integrity, service and system packages. Approval is needed when
11
11
  * either exists. Lamaste runs relay hooks, hosts the plugin and installs its
12
12
  * agent half only while the installed version's digest is the approved
13
- * one.
13
+ * one. Settings are outside the digest, but an update that turns a secret
14
+ * setting into a plain one waits for an administrator's approval all the
15
+ * same (`plugin-lifecycle.js`): the same pending update, approved here.
14
16
  *
15
17
  * The runner is about the relay part alone: it holds a root-owned copy of
16
18
  * the approved module that lamaste-priv installed after checking its
@@ -281,22 +283,67 @@ export async function hookStatus(plugin) {
281
283
  };
282
284
  }
283
285
 
286
+ /** Why an update waits: its approval digest is not the approved one. */
287
+ export const DIGEST_UNAPPROVED = 'digest.unapproved';
288
+ /** Why an update waits: it turns secret settings into plain ones. */
289
+ export const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
290
+
291
+ /**
292
+ * The reason a staged version waits because what it brings — its relay
293
+ * hooks (`relay`), the agent half Lamaste installs (`agentHalf`); either
294
+ * truthy when it brings one — is not what was approved.
295
+ *
296
+ * @returns {{ code: string, reason: string }}
297
+ */
298
+ export function digestUnapprovedReason({ relay, agentHalf }) {
299
+ const what =
300
+ relay && agentHalf
301
+ ? 'its relay hooks and agent half are'
302
+ : agentHalf
303
+ ? 'its agent half is'
304
+ : 'its relay hooks are';
305
+ return { code: DIGEST_UNAPPROVED, reason: `${what} not what was approved` };
306
+ }
307
+
308
+ /**
309
+ * Why a stored pending update waits for an administrator. A record staged
310
+ * before the reasons were stored waited for one reason only: its digest.
311
+ *
312
+ * @returns {Array<{ code: string, reason: string }>}
313
+ */
314
+ function pendingApprovalRequired(pending) {
315
+ return (
316
+ pending.approvalRequired ?? [
317
+ digestUnapprovedReason({ relay: pending.preview, agentHalf: pending.agentHalf }),
318
+ ]
319
+ );
320
+ }
321
+
284
322
  /**
285
323
  * A staged update waiting for approval as clients see it — in the hook
286
324
  * 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`.
325
+ * approve it with (null when it brings neither relay hooks nor an agent
326
+ * half Lamaste installs: it is approved by its version), what it brings
327
+ * (its relay hook set and the agent half Lamaste installs, each null when
328
+ * it brings none), how it changes the settings schema (`settings`:
329
+ * `{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key,
330
+ * from, to }], newRequired: [key], secretToPlain: [key] }`, null when
331
+ * neither version declares settings — not part of the digest), why it
332
+ * waits for an administrator (`approvalRequired`: `[{ code, reason }]`,
333
+ * `digest.unapproved` and `settings.secret-to-plain`; never empty), and —
334
+ * when the plugin has auto-approve on — why auto-approve did not cover it
335
+ * (`autoApproveWithheld`, sentences; null otherwise). The stored record
336
+ * names the relay hook set's preview `preview`.
292
337
  */
293
338
  export function pendingUpdateView(pending) {
294
339
  return {
295
340
  version: pending.version,
296
- digest: pending.digest,
341
+ digest: pending.digest ?? null,
297
342
  stagedAt: pending.stagedAt,
298
343
  relay: pending.preview ?? null,
299
344
  agentHalf: pending.agentHalf ?? null,
345
+ settings: pending.settings ?? null,
346
+ approvalRequired: pendingApprovalRequired(pending),
300
347
  autoApproveWithheld: pending.autoApproveWithheld ?? null,
301
348
  };
302
349
  }
@@ -416,28 +463,71 @@ export async function restartRelayHookRunner(plugin, logger) {
416
463
  /**
417
464
  * An administrator approves what a plugin version brings — its relay hook
418
465
  * 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.
466
+ * shown: the installed version's, or a pending update's. A pending update
467
+ * is also approved by naming its `version`, with its digest when it has
468
+ * one — the only way to approve one that brings neither relay hooks nor an
469
+ * agent half (its digest is null), and what binds the approval of a
470
+ * settings change to the release that was shown.
422
471
  *
423
- * @returns {Promise<{ target: 'installed' | 'update', digest: string }>}
472
+ * The installed version's approval is recorded here, in the hook state and
473
+ * the audit log. A pending update's is checked against the update that is
474
+ * waiting and handed back for the caller to apply (`updatePlugin` with
475
+ * `approvedUpdate`): its version, its digest and the secret settings it
476
+ * turns plain as they were shown. It approves everything the update waits
477
+ * for (`approvalRequired`), its digest changed or not, and is audited when
478
+ * the update starts under it.
479
+ *
480
+ * @param {object} plugin the installed registry entry
481
+ * @param {string | null} digest
482
+ * @param {{ actor: string, certSerial: string | null, logger?: object, version?: string | null }} options
483
+ * @returns {Promise<{ target: 'installed', digest: string } | { target: 'update', digest: string | null, version: string, secretToPlain: string[] }>}
424
484
  */
425
- export async function approveHookSet(plugin, digest, { actor, certSerial, logger }) {
485
+ export async function approveHookSet(
486
+ plugin,
487
+ digest,
488
+ { actor, certSerial, logger, version = null },
489
+ ) {
426
490
  const approval = installedApproval(plugin);
427
491
  const { relay } = approval;
428
492
  const state = await readHookState(plugin.name);
429
493
  const pending = state?.pendingUpdate ?? null;
430
494
  const now = new Date().toISOString();
431
495
 
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 };
496
+ if (version !== null) {
497
+ if (!pending) {
498
+ throw httpError(`Plugin "${plugin.name}" has no update waiting for approval`, 409);
499
+ }
500
+ if (pending.version !== version) {
501
+ throw httpError(
502
+ `The update waiting for approval is version ${pending.version}, not ${version} — review it again`,
503
+ 409,
504
+ );
505
+ }
506
+ const pendingDigest = pending.digest ?? null;
507
+ if (digest === null && pendingDigest !== null) {
508
+ throw httpError(
509
+ `Version ${version} brings relay hooks or an agent half — approve it with the approval digest it shows`,
510
+ 400,
511
+ );
512
+ }
513
+ if (digest !== pendingDigest) {
514
+ throw httpError(
515
+ `What version ${version} brings changed since it was shown — review it again`,
516
+ 409,
517
+ {
518
+ currentDigest: pendingDigest,
519
+ },
520
+ );
521
+ }
522
+ return pendingUpdateApproval(pending);
440
523
  }
524
+ if (digest === null) {
525
+ throw httpError(
526
+ "Approving names the approval digest that was shown, or a pending update's version",
527
+ 400,
528
+ );
529
+ }
530
+ if (pending && pending.digest === digest) return pendingUpdateApproval(pending);
441
531
  if (!approval.required) {
442
532
  throw httpError(
443
533
  `Plugin "${plugin.name}" has no relay hooks and no agent half Lamaste installs — nothing to approve`,
@@ -466,6 +556,16 @@ export async function approveHookSet(plugin, digest, { actor, certSerial, logger
466
556
  return { target: 'installed', digest };
467
557
  }
468
558
 
559
+ /** What approving the pending update `pending` hands its caller to apply: the update as it was shown. */
560
+ function pendingUpdateApproval(pending) {
561
+ return {
562
+ target: 'update',
563
+ digest: pending.digest ?? null,
564
+ version: pending.version,
565
+ secretToPlain: pending.settings?.secretToPlain ?? [],
566
+ };
567
+ }
568
+
469
569
  /**
470
570
  * Record an approval of an applied update (by an admin or auto-approve):
471
571
  * `digest` is the version's approval digest (`installedApproval`).