@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
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin settings on the relay — what an administrator configures for an
|
|
3
|
+
* agent-hosted plugin (the manifest's `settings` block; the contract and its
|
|
4
|
+
* validation are core's `plugin-settings.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The agent half is the system of record: it keeps the values in its own
|
|
7
|
+
* data directory and answers `GET` / `PUT /admin/settings` for Lamaste
|
|
8
|
+
* alone. The relay keeps a cache per plugin in state.db (`plugin_settings`)
|
|
9
|
+
* — the non-secret values the agent half stored, which secrets are set and
|
|
10
|
+
* when, whether the plugin still needs configuration — so the panel can show
|
|
11
|
+
* them while the agent is offline. Never a secret's value: the table has no
|
|
12
|
+
* column for one.
|
|
13
|
+
*
|
|
14
|
+
* A change is checked by the panel and stored by the agent half: the panel
|
|
15
|
+
* validates it against the installed schema (type, required, length, range,
|
|
16
|
+
* options — never `pattern`, which only the form and the agent half
|
|
17
|
+
* evaluate, so a manifest's regular expression cannot stall the panel) and
|
|
18
|
+
* sends it through the internal tunnel (`PUT /admin/settings`, caller
|
|
19
|
+
* `lamaste`), each secret sealed to that one request (core's
|
|
20
|
+
* `settings-secrets`), and answers with the agent half's verdict — once the
|
|
21
|
+
* answer has authenticated itself as the agent half's. A secret exists on
|
|
22
|
+
* the relay in clear only in the memory of that request: what the panel
|
|
23
|
+
* logs, stores, audits and answers is built from key names and the agent
|
|
24
|
+
* half's state, never from the request body, and no reference to it
|
|
25
|
+
* outlives the request.
|
|
26
|
+
*
|
|
27
|
+
* The cache is refreshed after a hosting succeeds, after an update, when the
|
|
28
|
+
* health monitor sees the plugin come up, once when the panel starts, on
|
|
29
|
+
* every live read and from the answer to every change; it goes when the
|
|
30
|
+
* hosting is removed and when the plugin is uninstalled. Reads and writes of
|
|
31
|
+
* one plugin's settings are serialized, so a slow read cannot put back what
|
|
32
|
+
* a change replaced. When the installed schema changes, the values and
|
|
33
|
+
* secrets whose field changed type leave the cache in the transaction that
|
|
34
|
+
* writes the plugin's row ({@link prunedSettingsCache}).
|
|
35
|
+
*
|
|
36
|
+
* Provenance: only Lamaste writes the settings (the forwarder reserves
|
|
37
|
+
* `/admin/settings*`), so every revision of the agent half's should be one
|
|
38
|
+
* Lamaste wrote. The cache remembers the last revision it wrote or adopted
|
|
39
|
+
* (`lamaste_revision`); a read that finds another found a change made
|
|
40
|
+
* outside Lamaste — by whatever holds the installation secret on the agent,
|
|
41
|
+
* or files put back there — and records it (audit
|
|
42
|
+
* `plugin.settings.changed-outside-lamaste` and a notification, key names
|
|
43
|
+
* only) before adopting it. A change whose answer never authenticated (a
|
|
44
|
+
* timeout, say) is remembered by its key names, so finding it stored later
|
|
45
|
+
* is recorded as the administrator's change, not as one made outside.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
AGENT_HALF_SETTINGS_MAX_BYTES,
|
|
50
|
+
AGENT_HALF_SETTINGS_PATH,
|
|
51
|
+
SETTINGS_KEY_RE,
|
|
52
|
+
SETTINGS_MAX_FIELDS,
|
|
53
|
+
callAgentHalf,
|
|
54
|
+
effectiveSettingsValues,
|
|
55
|
+
encryptSettingsSecret,
|
|
56
|
+
normalizeSettingsErrorMessage,
|
|
57
|
+
parseAgentHalfSettingsState,
|
|
58
|
+
settingsStatus,
|
|
59
|
+
validateSettingsUpdate,
|
|
60
|
+
} from '@lamalibre/lamaste';
|
|
61
|
+
import { pluginHostingPhase, signingSecretFor } from '@lamalibre/lamaste/server';
|
|
62
|
+
import { httpError } from './app-error.js';
|
|
63
|
+
import { agentLastSeenAt } from './agent-presence.js';
|
|
64
|
+
import { recordAuditBestEffort } from './audit.js';
|
|
65
|
+
import { notifyAdminsBestEffort } from './notifications.js';
|
|
66
|
+
import { findPlugin, readPlugins } from './plugins.js';
|
|
67
|
+
import { getPluginOperation, isPluginOperationRunning } from './plugin-operations.js';
|
|
68
|
+
import { getStateDb } from './state-db.js';
|
|
69
|
+
|
|
70
|
+
/** How long a read of the agent half's settings may take (the panel waits on it). */
|
|
71
|
+
export const SETTINGS_READ_TIMEOUT_MS = 5_000;
|
|
72
|
+
|
|
73
|
+
/** How long the agent half may take to store a change. */
|
|
74
|
+
export const SETTINGS_WRITE_TIMEOUT_MS = 15_000;
|
|
75
|
+
|
|
76
|
+
/** The audit action of a change of the agent half's settings that Lamaste did not make. */
|
|
77
|
+
export const SETTINGS_CHANGED_OUTSIDE_ACTION = 'plugin.settings.changed-outside-lamaste';
|
|
78
|
+
|
|
79
|
+
const UPDATE_KEYS = new Set(['revision', 'values', 'secrets']);
|
|
80
|
+
|
|
81
|
+
/** The key-name lists of a change, as its audit entry names them. */
|
|
82
|
+
const CHANGE_LISTS = ['changed', 'cleared', 'secretsSet', 'secretsCleared'];
|
|
83
|
+
|
|
84
|
+
function isRecord(value) {
|
|
85
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJson(text, fallback) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isRevision(value) {
|
|
97
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The settings block of a registry entry — its installed manifest's,
|
|
102
|
+
* validated when the version was installed or staged — or null when the
|
|
103
|
+
* plugin declares none.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ settings?: unknown } | null | undefined} plugin
|
|
106
|
+
* @returns {import('@lamalibre/lamaste').SettingsBlock | null}
|
|
107
|
+
*/
|
|
108
|
+
export function settingsBlockOf(plugin) {
|
|
109
|
+
const block = plugin?.settings;
|
|
110
|
+
if (!isRecord(block)) return null;
|
|
111
|
+
if (!Number.isSafeInteger(block.schemaVersion) || !Array.isArray(block.fields)) return null;
|
|
112
|
+
return block;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// The cache
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A change Lamaste sent whose answer never authenticated — key names only.
|
|
121
|
+
*
|
|
122
|
+
* @typedef {object} UnconfirmedChange
|
|
123
|
+
* @property {number} revision the revision the agent half would be at had it stored it
|
|
124
|
+
* @property {string} actor
|
|
125
|
+
* @property {string} [certSerial]
|
|
126
|
+
* @property {string[]} changed
|
|
127
|
+
* @property {string[]} cleared
|
|
128
|
+
* @property {string[]} secretsSet
|
|
129
|
+
* @property {string[]} secretsCleared
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @typedef {object} SettingsCache
|
|
134
|
+
* @property {number} schemaVersion the agent half's settings schema version
|
|
135
|
+
* @property {number} revision the agent half's revision
|
|
136
|
+
* @property {Record<string, string | number | boolean>} values stored non-secret values (no defaults)
|
|
137
|
+
* @property {Record<string, string>} secretsSet secret key → when it was set
|
|
138
|
+
* @property {boolean} configured
|
|
139
|
+
* @property {string[]} missing
|
|
140
|
+
* @property {string} syncedAt
|
|
141
|
+
* @property {number | null} lamasteRevision the revision Lamaste last wrote or adopted
|
|
142
|
+
* @property {UnconfirmedChange | null} unconfirmed
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
function isKeyList(value) {
|
|
146
|
+
return Array.isArray(value) && value.every((key) => typeof key === 'string');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A stored {@link UnconfirmedChange}, or null for anything else. */
|
|
150
|
+
function parseUnconfirmed(text) {
|
|
151
|
+
if (typeof text !== 'string') return null;
|
|
152
|
+
const raw = parseJson(text, null);
|
|
153
|
+
if (!isRecord(raw) || !isRevision(raw.revision) || typeof raw.actor !== 'string') return null;
|
|
154
|
+
if (!CHANGE_LISTS.every((list) => isKeyList(raw[list]))) return null;
|
|
155
|
+
return {
|
|
156
|
+
revision: raw.revision,
|
|
157
|
+
actor: raw.actor,
|
|
158
|
+
...(typeof raw.certSerial === 'string' ? { certSerial: raw.certSerial } : {}),
|
|
159
|
+
...Object.fromEntries(CHANGE_LISTS.map((list) => [list, [...raw[list]]])),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @returns {SettingsCache | null} */
|
|
164
|
+
function rowToCache(row) {
|
|
165
|
+
if (!row) return null;
|
|
166
|
+
const values = parseJson(row.values_json, {});
|
|
167
|
+
const secretsSet = parseJson(row.secrets_set_json, {});
|
|
168
|
+
const missing = parseJson(row.missing_json, []);
|
|
169
|
+
return {
|
|
170
|
+
schemaVersion: row.schema_version,
|
|
171
|
+
revision: row.revision,
|
|
172
|
+
values: isRecord(values) ? values : {},
|
|
173
|
+
secretsSet: isRecord(secretsSet) ? secretsSet : {},
|
|
174
|
+
configured: row.configured === 1,
|
|
175
|
+
missing: Array.isArray(missing) ? missing.filter((key) => typeof key === 'string') : [],
|
|
176
|
+
syncedAt: row.synced_at,
|
|
177
|
+
lamasteRevision: isRevision(row.lamaste_revision) ? row.lamaste_revision : null,
|
|
178
|
+
unconfirmed: parseUnconfirmed(row.unconfirmed_change_json),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The cached settings of plugin `name`, or null when the relay never read
|
|
184
|
+
* them (or the hosting they belonged to was removed).
|
|
185
|
+
*
|
|
186
|
+
* @param {string} name
|
|
187
|
+
* @returns {Promise<SettingsCache | null>}
|
|
188
|
+
*/
|
|
189
|
+
export async function readSettingsCache(name) {
|
|
190
|
+
const db = await getStateDb();
|
|
191
|
+
return rowToCache(db.prepare('SELECT * FROM plugin_settings WHERE plugin = ?').get(name));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @returns {Promise<Map<string, SettingsCache>>} */
|
|
195
|
+
async function readAllSettingsCaches() {
|
|
196
|
+
const db = await getStateDb();
|
|
197
|
+
return new Map(
|
|
198
|
+
db
|
|
199
|
+
.prepare('SELECT * FROM plugin_settings')
|
|
200
|
+
.all()
|
|
201
|
+
.map((row) => [row.plugin, rowToCache(row)]),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop the cached settings of plugin `name` (its hosting is gone). */
|
|
206
|
+
export async function deleteSettingsCache(name) {
|
|
207
|
+
const db = await getStateDb();
|
|
208
|
+
db.prepare('DELETE FROM plugin_settings WHERE plugin = ?').run(name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What a cache row becomes when the installed settings schema changes from
|
|
213
|
+
* `prev` to `next` — an update, its revert, an install: each stored value
|
|
214
|
+
* and each set secret stays only while its field keeps its type (a value
|
|
215
|
+
* whose field became a secret goes; a secret whose field became a plain
|
|
216
|
+
* value loses its `setAt`; a field the new schema lacks goes), and whether
|
|
217
|
+
* the plugin is configured is worked out again against `next` until the
|
|
218
|
+
* agent half reports its own view. Null when there is no row or the schema
|
|
219
|
+
* did not change; `{ delete: true }` when `next` declares no settings.
|
|
220
|
+
* Pure: `lib/plugins.js` applies it in the transaction that writes the
|
|
221
|
+
* plugin's row, so no read ever pairs the new schema with values of the
|
|
222
|
+
* old one.
|
|
223
|
+
*
|
|
224
|
+
* @param {Record<string, unknown> | null | undefined} row the `plugin_settings` row
|
|
225
|
+
* @param {import('@lamalibre/lamaste').SettingsBlock | null} prev
|
|
226
|
+
* @param {import('@lamalibre/lamaste').SettingsBlock | null} next
|
|
227
|
+
* @returns {null | { delete: true } | { values: Record<string, unknown>, secretsSet: Record<string, string>, configured: boolean, missing: string[] }}
|
|
228
|
+
*/
|
|
229
|
+
export function prunedSettingsCache(row, prev, next) {
|
|
230
|
+
if (!row) return null;
|
|
231
|
+
const nextBlock = settingsBlockOf({ settings: next });
|
|
232
|
+
if (!nextBlock) return { delete: true };
|
|
233
|
+
const prevBlock = settingsBlockOf({ settings: prev });
|
|
234
|
+
if (prevBlock && JSON.stringify(prevBlock) === JSON.stringify(nextBlock)) return null;
|
|
235
|
+
const before = new Map((prevBlock?.fields ?? []).map((field) => [field.key, field.type]));
|
|
236
|
+
const after = new Map(nextBlock.fields.map((field) => [field.key, field.type]));
|
|
237
|
+
const keeps = (key, secret) =>
|
|
238
|
+
before.has(key) &&
|
|
239
|
+
before.get(key) === after.get(key) &&
|
|
240
|
+
(after.get(key) === 'secret') === secret;
|
|
241
|
+
|
|
242
|
+
const storedValues = parseJson(row.values_json, {});
|
|
243
|
+
const storedSecrets = parseJson(row.secrets_set_json, {});
|
|
244
|
+
const values = Object.fromEntries(
|
|
245
|
+
Object.entries(isRecord(storedValues) ? storedValues : {}).filter(([key]) => keeps(key, false)),
|
|
246
|
+
);
|
|
247
|
+
const secretsSet = Object.fromEntries(
|
|
248
|
+
Object.entries(isRecord(storedSecrets) ? storedSecrets : {}).filter(
|
|
249
|
+
([key, setAt]) => typeof setAt === 'string' && keeps(key, true),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
const status = settingsStatus(nextBlock, { values, secretsSet: Object.keys(secretsSet) });
|
|
253
|
+
return { values, secretsSet, configured: status.configured, missing: status.missing };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The stored non-secret values the installed schema knows and that still
|
|
258
|
+
* suit their field (type, length, range, option) — the agent half's own
|
|
259
|
+
* schema may differ from the relay's while an update is under way.
|
|
260
|
+
*/
|
|
261
|
+
function knownStoredValues(block, values) {
|
|
262
|
+
const withoutDefaults = {
|
|
263
|
+
schemaVersion: block.schemaVersion,
|
|
264
|
+
fields: block.fields
|
|
265
|
+
.filter((field) => field.type !== 'secret')
|
|
266
|
+
.map((field) => ({ ...field, default: undefined })),
|
|
267
|
+
};
|
|
268
|
+
return effectiveSettingsValues(withoutDefaults, values);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The agent half's state of a plugin as the cache keeps it: its stored
|
|
273
|
+
* non-secret values and set secrets for the keys the installed schema
|
|
274
|
+
* knows, and its own `configured` / `missing`. The state never carries a
|
|
275
|
+
* secret's value (`parseAgentHalfSettingsState` keeps `setAt` alone).
|
|
276
|
+
*
|
|
277
|
+
* @param {import('@lamalibre/lamaste').SettingsBlock} block the installed schema
|
|
278
|
+
* @param {import('@lamalibre/lamaste').AgentHalfSettingsState} state
|
|
279
|
+
*/
|
|
280
|
+
function cacheOfState(block, state) {
|
|
281
|
+
return {
|
|
282
|
+
schemaVersion: state.schemaVersion,
|
|
283
|
+
revision: state.revision,
|
|
284
|
+
values: knownStoredValues(block, state.values),
|
|
285
|
+
secretsSet: Object.fromEntries(
|
|
286
|
+
block.fields
|
|
287
|
+
.filter((field) => field.type === 'secret' && Object.hasOwn(state.secrets, field.key))
|
|
288
|
+
.map((field) => [field.key, state.secrets[field.key].setAt]),
|
|
289
|
+
),
|
|
290
|
+
configured: state.configured,
|
|
291
|
+
missing: [...state.missing],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Store the cache of plugin `name` with its provenance — the revision
|
|
297
|
+
* Lamaste wrote or adopted, and a change whose answer it is still waiting
|
|
298
|
+
* to find stored.
|
|
299
|
+
*
|
|
300
|
+
* @returns {Promise<SettingsCache>}
|
|
301
|
+
*/
|
|
302
|
+
async function storeSettingsCache(name, cache, { lamasteRevision, unconfirmed }) {
|
|
303
|
+
const syncedAt = new Date().toISOString();
|
|
304
|
+
const db = await getStateDb();
|
|
305
|
+
db.prepare(
|
|
306
|
+
`INSERT INTO plugin_settings
|
|
307
|
+
(plugin, schema_version, revision, values_json, secrets_set_json, configured,
|
|
308
|
+
missing_json, synced_at, lamaste_revision, unconfirmed_change_json)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
310
|
+
ON CONFLICT(plugin) DO UPDATE SET
|
|
311
|
+
schema_version = excluded.schema_version,
|
|
312
|
+
revision = excluded.revision,
|
|
313
|
+
values_json = excluded.values_json,
|
|
314
|
+
secrets_set_json = excluded.secrets_set_json,
|
|
315
|
+
configured = excluded.configured,
|
|
316
|
+
missing_json = excluded.missing_json,
|
|
317
|
+
synced_at = excluded.synced_at,
|
|
318
|
+
lamaste_revision = excluded.lamaste_revision,
|
|
319
|
+
unconfirmed_change_json = excluded.unconfirmed_change_json`,
|
|
320
|
+
).run(
|
|
321
|
+
name,
|
|
322
|
+
cache.schemaVersion,
|
|
323
|
+
cache.revision,
|
|
324
|
+
JSON.stringify(cache.values),
|
|
325
|
+
JSON.stringify(cache.secretsSet),
|
|
326
|
+
cache.configured ? 1 : 0,
|
|
327
|
+
JSON.stringify(cache.missing),
|
|
328
|
+
syncedAt,
|
|
329
|
+
lamasteRevision,
|
|
330
|
+
unconfirmed ? JSON.stringify(unconfirmed) : null,
|
|
331
|
+
);
|
|
332
|
+
return { ...cache, syncedAt, lamasteRevision, unconfirmed };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Remember a change whose answer never authenticated, on the plugin's cache row (if it has one). */
|
|
336
|
+
async function recordUnconfirmedChange(name, change) {
|
|
337
|
+
const db = await getStateDb();
|
|
338
|
+
db.prepare('UPDATE plugin_settings SET unconfirmed_change_json = ? WHERE plugin = ?').run(
|
|
339
|
+
JSON.stringify(change),
|
|
340
|
+
name,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Provenance
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** The keys of `a` and `b` whose entry differs (present in one only, or another value). */
|
|
349
|
+
function differingKeys(a, b) {
|
|
350
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
351
|
+
return [...keys]
|
|
352
|
+
.filter((key) => Object.hasOwn(a, key) !== Object.hasOwn(b, key) || a[key] !== b[key])
|
|
353
|
+
.sort();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Which settings differ between two caches — names only: the non-secret
|
|
358
|
+
* values (`changed`) and the secrets set, replaced or cleared
|
|
359
|
+
* (`secretsChanged`, by their `setAt`).
|
|
360
|
+
*/
|
|
361
|
+
function changedSettings(before, after) {
|
|
362
|
+
return {
|
|
363
|
+
changed: differingKeys(before.values, after.values),
|
|
364
|
+
secretsChanged: differingKeys(before.secretsSet, after.secretsSet),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** `keys` without the keys an administrator's change named. */
|
|
369
|
+
function withoutChange(keys, change) {
|
|
370
|
+
const own = new Set(CHANGE_LISTS.flatMap((list) => change[list]));
|
|
371
|
+
return {
|
|
372
|
+
changed: keys.changed.filter((key) => !own.has(key)),
|
|
373
|
+
secretsChanged: keys.secretsChanged.filter((key) => !own.has(key)),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether every key in `keys` is one the unconfirmed change named. */
|
|
378
|
+
function withinChange(keys, change) {
|
|
379
|
+
const own = new Set(CHANGE_LISTS.flatMap((list) => change[list]));
|
|
380
|
+
return [...keys.changed, ...keys.secretsChanged].every((key) => own.has(key));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* What finding the agent half at `revision`, changed in `keys` since the
|
|
385
|
+
* cache `previous`, says about who changed it:
|
|
386
|
+
*
|
|
387
|
+
* - `baseline` — the relay never knew a revision of Lamaste's to compare
|
|
388
|
+
* with (no cache, or one from before provenance): adopted as it is;
|
|
389
|
+
* - `confirmed` — exactly the revision and keys of a change whose answer
|
|
390
|
+
* never authenticated: the administrator's change, stored after all;
|
|
391
|
+
* - `unchanged` — the revision Lamaste last wrote or adopted;
|
|
392
|
+
* - `outside` — any other revision, higher or lower: a change Lamaste did
|
|
393
|
+
* not make (only Lamaste can reach `/admin/settings` through the panel).
|
|
394
|
+
*
|
|
395
|
+
* @param {SettingsCache | null} previous
|
|
396
|
+
* @param {number} revision
|
|
397
|
+
* @param {{ changed: string[], secretsChanged: string[] }} keys
|
|
398
|
+
* @returns {'baseline' | 'confirmed' | 'unchanged' | 'outside'}
|
|
399
|
+
*/
|
|
400
|
+
function judgeProvenance(previous, revision, keys) {
|
|
401
|
+
if (!previous) return 'baseline';
|
|
402
|
+
const { unconfirmed } = previous;
|
|
403
|
+
if (unconfirmed && revision === unconfirmed.revision && withinChange(keys, unconfirmed)) {
|
|
404
|
+
return 'confirmed';
|
|
405
|
+
}
|
|
406
|
+
if (previous.lamasteRevision === null) return 'baseline';
|
|
407
|
+
return revision === previous.lamasteRevision ? 'unchanged' : 'outside';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Record an administrator's change that turned out stored after its answer failed. */
|
|
411
|
+
async function recordConfirmedChange(name, change, logger) {
|
|
412
|
+
await recordAuditBestEffort(
|
|
413
|
+
{
|
|
414
|
+
actor: change.actor,
|
|
415
|
+
action: 'plugin.settings.updated',
|
|
416
|
+
plugin: name,
|
|
417
|
+
details: {
|
|
418
|
+
...Object.fromEntries(CHANGE_LISTS.map((list) => [list, change[list]])),
|
|
419
|
+
revision: change.revision,
|
|
420
|
+
confirmedLater: true,
|
|
421
|
+
...(change.certSerial ? { certSerial: change.certSerial } : {}),
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
logger,
|
|
425
|
+
);
|
|
426
|
+
logger?.info(
|
|
427
|
+
{ plugin: name, revision: change.revision },
|
|
428
|
+
'A plugin settings change whose answer was lost turned out stored',
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Record a change of the agent half's settings that Lamaste did not make:
|
|
434
|
+
* an audit entry and a warning for the administrators, with key names and
|
|
435
|
+
* revisions — never a value.
|
|
436
|
+
*/
|
|
437
|
+
async function recordOutsideChange(name, record, { fromRevision, revision, keys }, logger) {
|
|
438
|
+
await recordAuditBestEffort(
|
|
439
|
+
{
|
|
440
|
+
actor: 'lamaste',
|
|
441
|
+
action: SETTINGS_CHANGED_OUTSIDE_ACTION,
|
|
442
|
+
plugin: name,
|
|
443
|
+
details: {
|
|
444
|
+
changed: keys.changed,
|
|
445
|
+
secretsChanged: keys.secretsChanged,
|
|
446
|
+
fromRevision,
|
|
447
|
+
revision,
|
|
448
|
+
agentLabel: record.agentLabel,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
logger,
|
|
452
|
+
);
|
|
453
|
+
const named = [...keys.changed, ...keys.secretsChanged];
|
|
454
|
+
await notifyAdminsBestEffort(
|
|
455
|
+
{
|
|
456
|
+
source: 'lamaste',
|
|
457
|
+
level: 'warning',
|
|
458
|
+
message:
|
|
459
|
+
`The settings of ${name} were changed outside Lamaste on agent "${record.agentLabel}" ` +
|
|
460
|
+
`(revision ${fromRevision} → ${revision}${named.length > 0 ? `: ${named.join(', ')}` : ''}). ` +
|
|
461
|
+
'Lamaste changes them only from the panel — check who holds the installation secret there, or whether files were restored.',
|
|
462
|
+
},
|
|
463
|
+
logger,
|
|
464
|
+
);
|
|
465
|
+
logger?.warn({ plugin: name, fromRevision, revision }, 'Plugin settings changed outside Lamaste');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Cache a state the agent half answered a read with, judging its
|
|
470
|
+
* provenance against the previous cache: adopted as it is, recorded as an
|
|
471
|
+
* administrator's change stored late, or recorded as a change made outside
|
|
472
|
+
* Lamaste — then adopted, so it is reported once.
|
|
473
|
+
*/
|
|
474
|
+
async function adoptReadState(name, record, block, state, logger) {
|
|
475
|
+
const previous = await readSettingsCache(name);
|
|
476
|
+
const next = cacheOfState(block, state);
|
|
477
|
+
const keys = previous ? changedSettings(previous, next) : { changed: [], secretsChanged: [] };
|
|
478
|
+
const verdict = judgeProvenance(previous, state.revision, keys);
|
|
479
|
+
const cache = await storeSettingsCache(name, next, {
|
|
480
|
+
lamasteRevision: verdict === 'unchanged' ? previous.lamasteRevision : state.revision,
|
|
481
|
+
// A change still unconfirmed may yet land while the revision is unchanged.
|
|
482
|
+
unconfirmed: verdict === 'unchanged' ? previous.unconfirmed : null,
|
|
483
|
+
});
|
|
484
|
+
if (verdict === 'confirmed') await recordConfirmedChange(name, previous.unconfirmed, logger);
|
|
485
|
+
if (verdict === 'outside') {
|
|
486
|
+
await recordOutsideChange(
|
|
487
|
+
name,
|
|
488
|
+
record,
|
|
489
|
+
{ fromRevision: previous.lamasteRevision, revision: state.revision, keys },
|
|
490
|
+
logger,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return cache;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// One plugin at a time
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/** plugin name → the tail of its chain of settings reads and writes */
|
|
501
|
+
const chains = new Map();
|
|
502
|
+
|
|
503
|
+
/** Run `fn` after every settings read or write of plugin `name` before it. */
|
|
504
|
+
function serialized(name, fn) {
|
|
505
|
+
const previous = chains.get(name) ?? Promise.resolve();
|
|
506
|
+
const next = previous.then(fn);
|
|
507
|
+
const tail = next.catch(() => undefined);
|
|
508
|
+
chains.set(name, tail);
|
|
509
|
+
void tail.finally(() => {
|
|
510
|
+
if (chains.get(name) === tail) chains.delete(name);
|
|
511
|
+
});
|
|
512
|
+
return next;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// The hosting and the agent half
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/** The hosting installed plugin `plugin` owns, or null (see `ownHosting`). */
|
|
520
|
+
async function ownHostingOf(plugin) {
|
|
521
|
+
const { ownHosting } = await import('./plugin-hosting.js');
|
|
522
|
+
return ownHosting(plugin);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Whether Lamaste calls the agent half of hosting `record`: active, and not known to be down. */
|
|
526
|
+
function isReachable(record) {
|
|
527
|
+
return pluginHostingPhase(record) === 'active' && record.health?.state !== 'down';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The hosting as a settings view shows it — where the agent half runs, the
|
|
532
|
+
* health monitor's view of it, and when its agent last synced — or null
|
|
533
|
+
* when the plugin is not hosted.
|
|
534
|
+
*/
|
|
535
|
+
async function hostingBlock(record) {
|
|
536
|
+
if (!record) return null;
|
|
537
|
+
return {
|
|
538
|
+
agentLabel: record.agentLabel,
|
|
539
|
+
phase: pluginHostingPhase(record),
|
|
540
|
+
state: record.health?.state ?? 'unknown',
|
|
541
|
+
reason: record.health?.reason ?? null,
|
|
542
|
+
since: record.health?.since ?? null,
|
|
543
|
+
agentLastSeenAt: await agentLastSeenAt(record.agentLabel),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Why plugin `name`'s settings cannot be changed now (its hosting is not active, or down). */
|
|
548
|
+
function offlineMessage(name, record) {
|
|
549
|
+
if (!record) {
|
|
550
|
+
return `Plugin "${name}" is not hosted on an agent — its settings are kept by its agent half`;
|
|
551
|
+
}
|
|
552
|
+
const phase = pluginHostingPhase(record);
|
|
553
|
+
if (phase !== 'active') {
|
|
554
|
+
return `Plugin "${name}" is ${phase} on agent "${record.agentLabel}" — its settings can be changed once it is active`;
|
|
555
|
+
}
|
|
556
|
+
if (record.health?.reason === 'app') {
|
|
557
|
+
return `The agent half of plugin "${name}" is not responding since ${record.health.since} — its settings can be changed when it answers again`;
|
|
558
|
+
}
|
|
559
|
+
return `Hosting agent "${record.agentLabel}" of plugin "${name}" is offline since ${record.health?.since} — its settings can be changed when it is back`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Read the agent half's settings through the tunnel; null (logged, with
|
|
564
|
+
* metadata only) when it cannot be read — including an answer that does
|
|
565
|
+
* not authenticate itself as the agent half's.
|
|
566
|
+
*/
|
|
567
|
+
async function readAgentHalfState(name, record, logger) {
|
|
568
|
+
const result = await callAgentHalf({
|
|
569
|
+
plugin: record.plugin,
|
|
570
|
+
port: record.port,
|
|
571
|
+
secret: signingSecretFor(record),
|
|
572
|
+
method: 'GET',
|
|
573
|
+
path: AGENT_HALF_SETTINGS_PATH,
|
|
574
|
+
timeoutMs: SETTINGS_READ_TIMEOUT_MS,
|
|
575
|
+
maxResponseBytes: AGENT_HALF_SETTINGS_MAX_BYTES,
|
|
576
|
+
});
|
|
577
|
+
if (!result.ok) {
|
|
578
|
+
logger?.warn(
|
|
579
|
+
{ plugin: name, code: result.code, err: result.error },
|
|
580
|
+
'Could not read the plugin settings from its agent half',
|
|
581
|
+
);
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
if (result.status !== 200) {
|
|
585
|
+
logger?.warn(
|
|
586
|
+
{ plugin: name, status: result.status },
|
|
587
|
+
'The agent half did not answer its settings',
|
|
588
|
+
);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const state = parseAgentHalfSettingsState(result.body);
|
|
592
|
+
if (!state) {
|
|
593
|
+
logger?.warn({ plugin: name }, 'The agent half answered its settings in an unexpected shape');
|
|
594
|
+
}
|
|
595
|
+
return state;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** {@link refreshPluginSettings} inside the plugin's chain. */
|
|
599
|
+
async function refreshUnserialized(name, logger) {
|
|
600
|
+
const plugin = await findPlugin(name);
|
|
601
|
+
const block = plugin ? settingsBlockOf(plugin) : null;
|
|
602
|
+
if (!block) return null;
|
|
603
|
+
const record = await ownHostingOf(plugin);
|
|
604
|
+
if (!record || pluginHostingPhase(record) !== 'active') return null;
|
|
605
|
+
const state = await readAgentHalfState(name, record, logger);
|
|
606
|
+
return state ? adoptReadState(name, record, block, state, logger) : null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Read plugin `name`'s settings from its agent half and cache them. Null
|
|
611
|
+
* when the plugin declares none, is not actively hosted, or its agent half
|
|
612
|
+
* could not be read (logged; the cache is kept).
|
|
613
|
+
*
|
|
614
|
+
* @param {string} name
|
|
615
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
616
|
+
* @returns {Promise<SettingsCache | null>}
|
|
617
|
+
*/
|
|
618
|
+
export function refreshPluginSettings(name, { logger } = {}) {
|
|
619
|
+
return serialized(name, () => refreshUnserialized(name, logger));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* {@link refreshPluginSettings} for the triggers beside other work — a
|
|
624
|
+
* hosting or an update that succeeded, the health monitor seeing the plugin
|
|
625
|
+
* come up, the panel starting. Never throws.
|
|
626
|
+
*/
|
|
627
|
+
export async function refreshPluginSettingsBestEffort(name, logger) {
|
|
628
|
+
try {
|
|
629
|
+
return await refreshPluginSettings(name, { logger });
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger?.warn({ plugin: name, err: err?.message }, 'Failed to refresh the plugin settings');
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Refresh the cache of every plugin that declares settings and whose
|
|
638
|
+
* hosting is active and not known to be down — one plugin after another,
|
|
639
|
+
* each in its own chain, best effort. The panel runs it once when it
|
|
640
|
+
* starts: the health monitor resumes from the persisted health, so a
|
|
641
|
+
* plugin that was up before the restart sees no transition that would
|
|
642
|
+
* refresh it, and changes made while the panel was down would go unseen
|
|
643
|
+
* (and unreported) until the next one. A hosting that is down is refreshed
|
|
644
|
+
* by its transition to up. Returns how many plugins were read.
|
|
645
|
+
*
|
|
646
|
+
* @param {import('pino').Logger} [logger]
|
|
647
|
+
* @returns {Promise<number>}
|
|
648
|
+
*/
|
|
649
|
+
export async function refreshHostedPluginSettings(logger) {
|
|
650
|
+
let read = 0;
|
|
651
|
+
let plugins = [];
|
|
652
|
+
try {
|
|
653
|
+
plugins = (await readPlugins()).plugins.filter((plugin) => settingsBlockOf(plugin) !== null);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
logger?.warn({ err: err?.message }, 'Could not list the plugins to refresh their settings');
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
for (const plugin of plugins) {
|
|
659
|
+
let record;
|
|
660
|
+
try {
|
|
661
|
+
record = await ownHostingOf(plugin);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
logger?.warn(
|
|
664
|
+
{ plugin: plugin.name, err: err?.message },
|
|
665
|
+
'Could not read the hosting to refresh the plugin settings',
|
|
666
|
+
);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (!record || !isReachable(record)) continue;
|
|
670
|
+
if (await refreshPluginSettingsBestEffort(plugin.name, logger)) read += 1;
|
|
671
|
+
}
|
|
672
|
+
return read;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// Views
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* What the panel shows: the fields, each non-secret field's effective value
|
|
681
|
+
* (stored, or its default) and each secret as `{ set: true, setAt }` or
|
|
682
|
+
* `{ set: false }`, whether the plugin still needs configuration, the
|
|
683
|
+
* revision to base a change on, where it came from (`live`: the agent half
|
|
684
|
+
* just answered; `cache`: the relay's last read; `none`: never read — the
|
|
685
|
+
* defaults) and the hosting.
|
|
686
|
+
*/
|
|
687
|
+
function settingsView(block, cache, source, hosting) {
|
|
688
|
+
const effective = effectiveSettingsValues(block, cache?.values ?? {});
|
|
689
|
+
const values = {};
|
|
690
|
+
for (const field of block.fields) {
|
|
691
|
+
if (field.type === 'secret') {
|
|
692
|
+
const setAt = cache?.secretsSet[field.key];
|
|
693
|
+
values[field.key] = typeof setAt === 'string' ? { set: true, setAt } : { set: false };
|
|
694
|
+
} else if (Object.hasOwn(effective, field.key)) {
|
|
695
|
+
values[field.key] = effective[field.key];
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const status = cache
|
|
699
|
+
? { configured: cache.configured, missing: cache.missing }
|
|
700
|
+
: settingsStatus(block, { values: {}, secretsSet: [] });
|
|
701
|
+
return {
|
|
702
|
+
schemaVersion: block.schemaVersion,
|
|
703
|
+
fields: block.fields,
|
|
704
|
+
values,
|
|
705
|
+
configured: status.configured,
|
|
706
|
+
missing: status.missing,
|
|
707
|
+
revision: cache?.revision ?? null,
|
|
708
|
+
source,
|
|
709
|
+
syncedAt: cache?.syncedAt ?? null,
|
|
710
|
+
hosting,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Plugin `name`'s settings: read from its agent half when its hosting is
|
|
716
|
+
* active and not down (and cached), otherwise from the cache. 404 when the
|
|
717
|
+
* plugin is not installed or declares no settings (`no_settings`).
|
|
718
|
+
*
|
|
719
|
+
* @param {string} name
|
|
720
|
+
* @param {{ logger?: import('pino').Logger }} [opts]
|
|
721
|
+
*/
|
|
722
|
+
export async function getPluginSettingsView(name, { logger } = {}) {
|
|
723
|
+
const plugin = await findPlugin(name);
|
|
724
|
+
if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
|
|
725
|
+
const block = settingsBlockOf(plugin);
|
|
726
|
+
if (!block) {
|
|
727
|
+
throw httpError(`Plugin "${name}" declares no settings`, 404, { code: 'no_settings' });
|
|
728
|
+
}
|
|
729
|
+
const record = await ownHostingOf(plugin);
|
|
730
|
+
let cache = null;
|
|
731
|
+
let source = 'none';
|
|
732
|
+
if (record && isReachable(record)) {
|
|
733
|
+
cache = await refreshPluginSettingsBestEffort(name, logger);
|
|
734
|
+
if (cache) source = 'live';
|
|
735
|
+
}
|
|
736
|
+
if (!cache) {
|
|
737
|
+
cache = await readSettingsCache(name);
|
|
738
|
+
if (cache) source = 'cache';
|
|
739
|
+
}
|
|
740
|
+
return settingsView(block, cache, source, await hostingBlock(record));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The plugins of a list, each that declares settings with `settings` as a
|
|
745
|
+
* summary — `{ configured, missing, syncedAt }` from the cache (from the
|
|
746
|
+
* schema's defaults while the relay never read them: `syncedAt` null) — in
|
|
747
|
+
* place of its schema. Never a value.
|
|
748
|
+
*
|
|
749
|
+
* @param {Array<object>} plugins registry entries
|
|
750
|
+
*/
|
|
751
|
+
export async function attachSettingsSummaries(plugins) {
|
|
752
|
+
const caches = await readAllSettingsCaches();
|
|
753
|
+
return plugins.map((plugin) => {
|
|
754
|
+
const block = settingsBlockOf(plugin);
|
|
755
|
+
if (!block) {
|
|
756
|
+
const rest = { ...plugin };
|
|
757
|
+
delete rest.settings;
|
|
758
|
+
return rest;
|
|
759
|
+
}
|
|
760
|
+
const cache = caches.get(plugin.name) ?? null;
|
|
761
|
+
const status = cache
|
|
762
|
+
? { configured: cache.configured, missing: cache.missing }
|
|
763
|
+
: settingsStatus(block, { values: {}, secretsSet: [] });
|
|
764
|
+
return {
|
|
765
|
+
...plugin,
|
|
766
|
+
settings: {
|
|
767
|
+
configured: status.configured,
|
|
768
|
+
missing: status.missing,
|
|
769
|
+
syncedAt: cache?.syncedAt ?? null,
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The `settings` of a relay hook's lifecycle input: the plugin's effective
|
|
777
|
+
* non-secret values, from the cache — `{}` when the plugin declares no
|
|
778
|
+
* settings or the relay never read them. Never a secret. (Agent hooks get
|
|
779
|
+
* `{}`: the agent half holds the settings itself.)
|
|
780
|
+
*
|
|
781
|
+
* @param {object} plugin registry entry (or a staged version in that shape)
|
|
782
|
+
*/
|
|
783
|
+
export async function relayHookSettings(plugin) {
|
|
784
|
+
const block = settingsBlockOf(plugin);
|
|
785
|
+
if (!block) return {};
|
|
786
|
+
const cache = await readSettingsCache(plugin.name);
|
|
787
|
+
return cache ? effectiveSettingsValues(block, cache.values) : {};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Changing settings
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* The request body's envelope `{ revision, values?, secrets? }`, checked
|
|
796
|
+
* without repeating anything it carries.
|
|
797
|
+
*/
|
|
798
|
+
function parseUpdateEnvelope(body) {
|
|
799
|
+
if (!isRecord(body)) {
|
|
800
|
+
return { ok: false, error: 'The body must be a JSON object: { revision, values?, secrets? }' };
|
|
801
|
+
}
|
|
802
|
+
if (Object.keys(body).some((key) => !UPDATE_KEYS.has(key))) {
|
|
803
|
+
return { ok: false, error: 'The body carries only revision, values and secrets' };
|
|
804
|
+
}
|
|
805
|
+
const { revision } = body;
|
|
806
|
+
if (!isRevision(revision)) {
|
|
807
|
+
return {
|
|
808
|
+
ok: false,
|
|
809
|
+
error: '"revision" must be the revision the settings were read at (an integer of at least 0)',
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return { ok: true, revision, values: body.values, secrets: body.secrets };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** 400 (the panel's check) or 422 (the agent half's), with a message per field. */
|
|
816
|
+
function invalidSettings(errors, statusCode = 400) {
|
|
817
|
+
return httpError('Invalid settings', statusCode, { errors });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The agent half's per-field messages as the relay passes them on: only
|
|
822
|
+
* under the keys of the installed schema (or `_`), and only messages
|
|
823
|
+
* Lamaste's own validation produces — anything else reads "Invalid value"
|
|
824
|
+
* (core's `normalizeSettingsErrorMessage`). The agent half is Lamaste code
|
|
825
|
+
* that never repeats a value, but the relay does not answer an
|
|
826
|
+
* administrator with text it cannot vouch for.
|
|
827
|
+
*/
|
|
828
|
+
function agentHalfErrors(block, body) {
|
|
829
|
+
const known = new Set(block.fields.map((field) => field.key));
|
|
830
|
+
const raw = isRecord(body) && isRecord(body.errors) ? body.errors : {};
|
|
831
|
+
const out = [];
|
|
832
|
+
for (const [key, message] of Object.entries(raw).slice(0, SETTINGS_MAX_FIELDS + 1)) {
|
|
833
|
+
if (key !== '_' && !(SETTINGS_KEY_RE.test(key) && known.has(key))) continue;
|
|
834
|
+
out.push([key, normalizeSettingsErrorMessage(message)]);
|
|
835
|
+
}
|
|
836
|
+
if (out.length === 0) out.push(['_', 'The agent half refused the values']);
|
|
837
|
+
return Object.fromEntries(out);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The agent half's 409, as the panel answers it: a message of its own, the code, the revision. */
|
|
841
|
+
function agentHalfConflict(body) {
|
|
842
|
+
const code = isRecord(body) ? body.code : undefined;
|
|
843
|
+
if (code === 'revision_mismatch') {
|
|
844
|
+
const revision = isRevision(body.revision) ? body.revision : null;
|
|
845
|
+
return httpError(
|
|
846
|
+
'The settings changed since they were read — reload them and make the change again',
|
|
847
|
+
409,
|
|
848
|
+
{ code, ...(revision !== null ? { revision } : {}) },
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (code === 'schema_mismatch') {
|
|
852
|
+
return httpError(
|
|
853
|
+
'The agent half runs another version of the settings schema — let the update of the plugin finish, then make the change again',
|
|
854
|
+
409,
|
|
855
|
+
{ code },
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
return httpError('The agent half refused the change as conflicting', 409, { code: 'conflict' });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* A call through the tunnel that got no answer Lamaste can believe: 504
|
|
863
|
+
* when it timed out, 502 otherwise — `unauthenticated` when something
|
|
864
|
+
* answered without the agent half's responder signature.
|
|
865
|
+
*/
|
|
866
|
+
function agentHalfUnreachable(name, result) {
|
|
867
|
+
switch (result.code) {
|
|
868
|
+
case 'refused':
|
|
869
|
+
return httpError(
|
|
870
|
+
`Nothing answers on the internal tunnel of plugin "${name}" — its hosting agent or agent half is down`,
|
|
871
|
+
502,
|
|
872
|
+
{ code: 'refused' },
|
|
873
|
+
);
|
|
874
|
+
case 'timeout':
|
|
875
|
+
return httpError(
|
|
876
|
+
`The agent half of plugin "${name}" did not answer within ${SETTINGS_WRITE_TIMEOUT_MS / 1000} s — the change may or may not have been stored; reload the settings`,
|
|
877
|
+
504,
|
|
878
|
+
{ code: 'timeout' },
|
|
879
|
+
);
|
|
880
|
+
case 'unauthenticated':
|
|
881
|
+
return httpError(
|
|
882
|
+
`The agent half of plugin "${name}" did not authenticate its answer — something else may hold its internal tunnel's port, or the agent half was built against an older @lamalibre/lamaste and must be rebuilt. The change may or may not have been stored; reload the settings`,
|
|
883
|
+
502,
|
|
884
|
+
{ code: 'unauthenticated' },
|
|
885
|
+
);
|
|
886
|
+
case 'invalid':
|
|
887
|
+
return httpError(`The agent half of plugin "${name}" answered with too large a body`, 502, {
|
|
888
|
+
code: 'invalid',
|
|
889
|
+
});
|
|
890
|
+
default:
|
|
891
|
+
return httpError(`The agent half of plugin "${name}" could not be reached`, 502, {
|
|
892
|
+
code: 'other',
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Change plugin `name`'s settings: `body` is the request's
|
|
899
|
+
* `{ revision, values?, secrets? }` — partial; `null` puts a non-secret field
|
|
900
|
+
* back to its default and clears a secret (refused for a required field).
|
|
901
|
+
* Checked here without `pattern` (400 `{ errors }`), refused while a plugin
|
|
902
|
+
* operation runs (409 `operation_running`) and while the hosting is not
|
|
903
|
+
* active or its health is down (503 `hosting_offline`, with the hosting),
|
|
904
|
+
* then sent to the agent half — each secret sealed to the request — whose
|
|
905
|
+
* authenticated answer decides: its new state (cached, audited with key
|
|
906
|
+
* names only, returned as the settings view), its 409 (`revision_mismatch`
|
|
907
|
+
* with the current revision, `schema_mismatch`), its 422 (`errors`,
|
|
908
|
+
* normalized), or 502 `settings_unsupported` when it does not answer
|
|
909
|
+
* `/admin/settings`. An answer that does not authenticate itself is 502
|
|
910
|
+
* `unauthenticated`. An empty change stores nothing and returns the
|
|
911
|
+
* settings as they are.
|
|
912
|
+
*
|
|
913
|
+
* Nothing derived from `body` is logged, stored or thrown, and nothing keeps
|
|
914
|
+
* it once this returns.
|
|
915
|
+
*
|
|
916
|
+
* @param {string} name
|
|
917
|
+
* @param {unknown} body
|
|
918
|
+
* @param {{ actor: string, certSerial?: string | null, logger?: import('pino').Logger }} opts
|
|
919
|
+
*/
|
|
920
|
+
export function updatePluginSettings(name, body, { actor, certSerial = null, logger }) {
|
|
921
|
+
return serialized(name, async () => {
|
|
922
|
+
const plugin = await findPlugin(name);
|
|
923
|
+
if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
|
|
924
|
+
const block = settingsBlockOf(plugin);
|
|
925
|
+
if (!block) {
|
|
926
|
+
throw httpError(`Plugin "${name}" declares no settings`, 404, { code: 'no_settings' });
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const envelope = parseUpdateEnvelope(body);
|
|
930
|
+
if (!envelope.ok) throw invalidSettings({ _: envelope.error });
|
|
931
|
+
const checked = validateSettingsUpdate(
|
|
932
|
+
block,
|
|
933
|
+
{ values: envelope.values, secrets: envelope.secrets },
|
|
934
|
+
{ checkPattern: false },
|
|
935
|
+
);
|
|
936
|
+
if (!checked.ok) throw invalidSettings(checked.errors);
|
|
937
|
+
|
|
938
|
+
if (isPluginOperationRunning(name)) {
|
|
939
|
+
const running = await getPluginOperation(name);
|
|
940
|
+
throw httpError(
|
|
941
|
+
`Plugin "${name}" has an operation in progress — change its settings once it finished`,
|
|
942
|
+
409,
|
|
943
|
+
{
|
|
944
|
+
code: 'operation_running',
|
|
945
|
+
...(running?.status === 'running' ? { operationId: running.id } : {}),
|
|
946
|
+
},
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
const record = await ownHostingOf(plugin);
|
|
950
|
+
if (!record || !isReachable(record)) {
|
|
951
|
+
throw httpError(offlineMessage(name, record), 503, {
|
|
952
|
+
code: 'hosting_offline',
|
|
953
|
+
hosting: await hostingBlock(record),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const change = {
|
|
958
|
+
changed: Object.keys(checked.values).filter((key) => checked.values[key] !== null),
|
|
959
|
+
cleared: Object.keys(checked.values).filter((key) => checked.values[key] === null),
|
|
960
|
+
secretsSet: Object.keys(checked.secrets).filter((key) => checked.secrets[key] !== null),
|
|
961
|
+
secretsCleared: Object.keys(checked.secrets).filter((key) => checked.secrets[key] === null),
|
|
962
|
+
};
|
|
963
|
+
if (CHANGE_LISTS.every((list) => change[list].length === 0)) {
|
|
964
|
+
// Nothing to store: the settings as they are.
|
|
965
|
+
let cache = await refreshUnserialized(name, logger);
|
|
966
|
+
let source = cache ? 'live' : 'none';
|
|
967
|
+
if (!cache) {
|
|
968
|
+
cache = await readSettingsCache(name);
|
|
969
|
+
if (cache) source = 'cache';
|
|
970
|
+
}
|
|
971
|
+
return settingsView(block, cache, source, await hostingBlock(record));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Each secret is sealed to this one request: under the secret the call
|
|
975
|
+
// is signed with, bound to the plugin, the assertion's nonce, the key
|
|
976
|
+
// and the revision (core's settings-secrets), so nothing that takes the
|
|
977
|
+
// tunnel's port can read it.
|
|
978
|
+
const signingSecret = signingSecretFor(record);
|
|
979
|
+
const result = await callAgentHalf({
|
|
980
|
+
plugin: record.plugin,
|
|
981
|
+
port: record.port,
|
|
982
|
+
secret: signingSecret,
|
|
983
|
+
method: 'PUT',
|
|
984
|
+
path: AGENT_HALF_SETTINGS_PATH,
|
|
985
|
+
body: (nonce) => ({
|
|
986
|
+
schemaVersion: block.schemaVersion,
|
|
987
|
+
baseRevision: envelope.revision,
|
|
988
|
+
values: checked.values,
|
|
989
|
+
secrets: Object.fromEntries(
|
|
990
|
+
Object.entries(checked.secrets).map(([key, value]) => [
|
|
991
|
+
key,
|
|
992
|
+
value === null
|
|
993
|
+
? null
|
|
994
|
+
: encryptSettingsSecret({
|
|
995
|
+
secret: signingSecret,
|
|
996
|
+
plugin: record.plugin,
|
|
997
|
+
nonce,
|
|
998
|
+
key,
|
|
999
|
+
baseRevision: envelope.revision,
|
|
1000
|
+
value,
|
|
1001
|
+
}),
|
|
1002
|
+
]),
|
|
1003
|
+
),
|
|
1004
|
+
}),
|
|
1005
|
+
timeoutMs: SETTINGS_WRITE_TIMEOUT_MS,
|
|
1006
|
+
maxResponseBytes: AGENT_HALF_SETTINGS_MAX_BYTES,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// The agent half may have stored a change whose answer Lamaste cannot
|
|
1010
|
+
// believe: remembered by key names, so that finding it stored later is
|
|
1011
|
+
// the administrator's change and not one made outside Lamaste.
|
|
1012
|
+
const unconfirmed = {
|
|
1013
|
+
revision: envelope.revision + 1,
|
|
1014
|
+
actor,
|
|
1015
|
+
...(certSerial ? { certSerial } : {}),
|
|
1016
|
+
...change,
|
|
1017
|
+
};
|
|
1018
|
+
if (!result.ok) {
|
|
1019
|
+
logger?.warn(
|
|
1020
|
+
{ plugin: name, code: result.code, err: result.error },
|
|
1021
|
+
'Could not send the settings change to the agent half',
|
|
1022
|
+
);
|
|
1023
|
+
if (result.code !== 'refused') await recordUnconfirmedChange(name, unconfirmed);
|
|
1024
|
+
throw agentHalfUnreachable(name, result);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
switch (result.status) {
|
|
1028
|
+
case 200: {
|
|
1029
|
+
const state = parseAgentHalfSettingsState(result.body);
|
|
1030
|
+
if (!state) {
|
|
1031
|
+
logger?.warn({ plugin: name }, 'The agent half answered a settings change unexpectedly');
|
|
1032
|
+
await recordUnconfirmedChange(name, unconfirmed);
|
|
1033
|
+
throw httpError(
|
|
1034
|
+
`The agent half of plugin "${name}" answered the change in an unexpected shape — reload the settings to see what it stored`,
|
|
1035
|
+
502,
|
|
1036
|
+
{ code: 'invalid' },
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const previous = await readSettingsCache(name);
|
|
1040
|
+
const next = cacheOfState(block, state);
|
|
1041
|
+
// The change was made on `envelope.revision`: one Lamaste wrote or
|
|
1042
|
+
// adopted, unless the administrator named a revision the relay
|
|
1043
|
+
// never saw — then what changed before it is reported too.
|
|
1044
|
+
const before = withoutChange(
|
|
1045
|
+
previous ? changedSettings(previous, next) : { changed: [], secretsChanged: [] },
|
|
1046
|
+
change,
|
|
1047
|
+
);
|
|
1048
|
+
const verdict = judgeProvenance(previous, envelope.revision, before);
|
|
1049
|
+
const cache = await storeSettingsCache(name, next, {
|
|
1050
|
+
lamasteRevision: state.revision,
|
|
1051
|
+
unconfirmed: null,
|
|
1052
|
+
});
|
|
1053
|
+
if (verdict === 'confirmed')
|
|
1054
|
+
await recordConfirmedChange(name, previous.unconfirmed, logger);
|
|
1055
|
+
if (verdict === 'outside') {
|
|
1056
|
+
await recordOutsideChange(
|
|
1057
|
+
name,
|
|
1058
|
+
record,
|
|
1059
|
+
{ fromRevision: previous.lamasteRevision, revision: envelope.revision, keys: before },
|
|
1060
|
+
logger,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
await recordAuditBestEffort(
|
|
1064
|
+
{
|
|
1065
|
+
actor,
|
|
1066
|
+
action: 'plugin.settings.updated',
|
|
1067
|
+
plugin: name,
|
|
1068
|
+
details: {
|
|
1069
|
+
...change,
|
|
1070
|
+
revision: state.revision,
|
|
1071
|
+
...(certSerial ? { certSerial } : {}),
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
logger,
|
|
1075
|
+
);
|
|
1076
|
+
logger?.info({ plugin: name, revision: state.revision }, 'Plugin settings changed');
|
|
1077
|
+
return settingsView(block, cache, 'live', await hostingBlock(record));
|
|
1078
|
+
}
|
|
1079
|
+
case 409:
|
|
1080
|
+
throw agentHalfConflict(result.body);
|
|
1081
|
+
case 422:
|
|
1082
|
+
throw invalidSettings(agentHalfErrors(block, result.body), 422);
|
|
1083
|
+
case 404:
|
|
1084
|
+
throw httpError(
|
|
1085
|
+
`The agent half of plugin "${name}" does not answer ${AGENT_HALF_SETTINGS_PATH} — it predates plugin settings`,
|
|
1086
|
+
502,
|
|
1087
|
+
{ code: 'settings_unsupported' },
|
|
1088
|
+
);
|
|
1089
|
+
default:
|
|
1090
|
+
logger?.warn(
|
|
1091
|
+
{ plugin: name, status: result.status },
|
|
1092
|
+
'The agent half refused a settings change',
|
|
1093
|
+
);
|
|
1094
|
+
throw httpError(
|
|
1095
|
+
`The agent half of plugin "${name}" answered ${result.status} to the settings change`,
|
|
1096
|
+
502,
|
|
1097
|
+
{ code: 'agent_half_error', status: result.status },
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|