@lamalibre/create-lamaste 4.3.1 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- 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 +38 -1
- 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 +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +11 -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 +394 -34
- 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 +34 -12
- 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 +3 -1
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +2 -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 +90 -85
- 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/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +4 -1
- package/vendor/server/package.json +4 -3
- 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 +36 -4
- 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-roDHz3dM.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-packages.js +30 -22
- package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
- package/vendor/serverd/src/lib/plugins.js +108 -33
- package/vendor/serverd/src/lib/registry-credential.js +37 -0
- 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-BTlBt_cD.js +0 -84
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
import { listRegisteredSubScopeNames } from './tickets.js';
|
|
26
26
|
import { getStateDb } from './state-db.js';
|
|
27
27
|
import { revokePluginCapabilitiesFromAgents } from './mtls.js';
|
|
28
|
+
import { withRegistryUserconfig } from './registry-credential.js';
|
|
29
|
+
import { RegistryCredentialError } from '@lamalibre/lamaste/server';
|
|
28
30
|
import {
|
|
29
31
|
bareLamalibrePackageName,
|
|
30
32
|
installedHookEntrySha256,
|
|
@@ -69,10 +71,19 @@ async function getStmts() {
|
|
|
69
71
|
INSERT INTO plugins
|
|
70
72
|
(name, route, display_name, package_name, version, description, capabilities,
|
|
71
73
|
packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
|
|
72
|
-
agent_half_pin, status, installed_at, enabled_at)
|
|
73
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
|
+
agent_half_pin, settings, status, installed_at, enabled_at)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
76
|
`),
|
|
75
77
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
78
|
+
// The relay's cache of the plugin's settings (lib/plugin-settings.js)
|
|
79
|
+
// goes with its row, and follows its settings schema.
|
|
80
|
+
deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
|
|
81
|
+
selectSettingsByName: db.prepare('SELECT * FROM plugin_settings WHERE plugin = ?'),
|
|
82
|
+
updatePrunedSettings: db.prepare(`
|
|
83
|
+
UPDATE plugin_settings
|
|
84
|
+
SET values_json = ?, secrets_set_json = ?, configured = ?, missing_json = ?
|
|
85
|
+
WHERE plugin = ?
|
|
86
|
+
`),
|
|
76
87
|
deleteAll: db.prepare('DELETE FROM plugins'),
|
|
77
88
|
updateStatusEnable: db.prepare(`
|
|
78
89
|
UPDATE plugins SET status = 'enabled', enabled_at = ? WHERE name = ?
|
|
@@ -125,6 +136,7 @@ function rowToPlugin(row) {
|
|
|
125
136
|
if (row.hooks != null) entry.hooks = JSON.parse(row.hooks);
|
|
126
137
|
if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
|
|
127
138
|
if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
|
|
139
|
+
if (row.settings != null) entry.settings = JSON.parse(row.settings);
|
|
128
140
|
if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
|
|
129
141
|
return entry;
|
|
130
142
|
}
|
|
@@ -183,6 +195,7 @@ export async function writePlugins(data) {
|
|
|
183
195
|
nullableJson(p.hooks),
|
|
184
196
|
nullableString(p.hooksEntrySha256),
|
|
185
197
|
nullableJson(p.agentHalfPin),
|
|
198
|
+
nullableJson(p.settings),
|
|
186
199
|
p.status,
|
|
187
200
|
p.installedAt,
|
|
188
201
|
nullableString(p.enabledAt),
|
|
@@ -315,6 +328,17 @@ export async function manifestPolicyError(manifest) {
|
|
|
315
328
|
return null;
|
|
316
329
|
}
|
|
317
330
|
|
|
331
|
+
/**
|
|
332
|
+
* What a failed install of `spec` is reported as. A registry that refused
|
|
333
|
+
* the panel's credential says so (the registry host and the status, from
|
|
334
|
+
* {@link withRegistryUserconfig}); anything else is npm's failure, whose
|
|
335
|
+
* output is in the log.
|
|
336
|
+
*/
|
|
337
|
+
function installFailure(spec, err) {
|
|
338
|
+
const detail = err instanceof RegistryCredentialError ? `: ${err.message}` : '';
|
|
339
|
+
return `Failed to install "${spec}"${detail}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
318
342
|
export function installPlugin(packageName, logger) {
|
|
319
343
|
return withPluginLock(async () => {
|
|
320
344
|
// Validate package scope
|
|
@@ -347,19 +371,25 @@ export function installPlugin(packageName, logger) {
|
|
|
347
371
|
}
|
|
348
372
|
}
|
|
349
373
|
|
|
350
|
-
// Install the npm package
|
|
374
|
+
// Install the npm package — from the configured registry, as the
|
|
375
|
+
// panel's own identity, when a registry credential is set.
|
|
351
376
|
logger.info({ packageName: installSpec }, 'Installing plugin package');
|
|
352
377
|
try {
|
|
353
|
-
await
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
378
|
+
await withRegistryUserconfig(
|
|
379
|
+
(env) =>
|
|
380
|
+
execa('npm', ['install', '--ignore-scripts', installSpec], {
|
|
381
|
+
cwd: STATE_DIR,
|
|
382
|
+
timeout: 120000,
|
|
383
|
+
env,
|
|
384
|
+
}),
|
|
385
|
+
{ log: logger },
|
|
386
|
+
);
|
|
357
387
|
} catch (err) {
|
|
358
388
|
logger.error(
|
|
359
389
|
{ packageName: installSpec, stderr: err.stderr, err: err.message },
|
|
360
390
|
'npm install failed',
|
|
361
391
|
);
|
|
362
|
-
throw Object.assign(new Error(
|
|
392
|
+
throw Object.assign(new Error(installFailure(installSpec, err)), { statusCode: 500 });
|
|
363
393
|
}
|
|
364
394
|
|
|
365
395
|
// Read the plugin manifest from the installed package
|
|
@@ -448,12 +478,16 @@ export function installPlugin(packageName, logger) {
|
|
|
448
478
|
hooks: manifest.hooks,
|
|
449
479
|
hooksEntrySha256,
|
|
450
480
|
agentHalfPin,
|
|
481
|
+
settings: manifest.settings,
|
|
451
482
|
status: 'disabled',
|
|
452
483
|
installedAt: new Date().toISOString(),
|
|
453
484
|
};
|
|
454
485
|
|
|
455
486
|
s.begin.run();
|
|
456
487
|
try {
|
|
488
|
+
// No settings are cached for a plugin that is not installed (its
|
|
489
|
+
// uninstall dropped them); none from before may meet this schema.
|
|
490
|
+
s.deleteSettingsByName.run(entry.name);
|
|
457
491
|
s.insert.run(
|
|
458
492
|
entry.name,
|
|
459
493
|
entry.route,
|
|
@@ -471,6 +505,7 @@ export function installPlugin(packageName, logger) {
|
|
|
471
505
|
nullableJson(entry.hooks),
|
|
472
506
|
nullableString(entry.hooksEntrySha256),
|
|
473
507
|
nullableJson(entry.agentHalfPin),
|
|
508
|
+
nullableJson(entry.settings),
|
|
474
509
|
entry.status,
|
|
475
510
|
entry.installedAt,
|
|
476
511
|
null,
|
|
@@ -543,10 +578,13 @@ export function uninstallPlugin(name, logger) {
|
|
|
543
578
|
logger.warn({ err: err.message }, 'Failed to remove plugin state directory');
|
|
544
579
|
});
|
|
545
580
|
|
|
546
|
-
// Remove from registry
|
|
581
|
+
// Remove from registry, with the cache of its settings (the agent half
|
|
582
|
+
// holds the settings themselves; a purge removed them, or they stay with
|
|
583
|
+
// the data an agent keeps of it)
|
|
547
584
|
s.begin.run();
|
|
548
585
|
try {
|
|
549
586
|
s.deleteByName.run(name);
|
|
587
|
+
s.deleteSettingsByName.run(name);
|
|
550
588
|
s.commit.run();
|
|
551
589
|
} catch (err) {
|
|
552
590
|
s.rollback.run();
|
|
@@ -593,34 +631,66 @@ const updateRowSql = `
|
|
|
593
631
|
UPDATE plugins SET
|
|
594
632
|
display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
|
|
595
633
|
config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
|
|
596
|
-
agent_half_pin = ?
|
|
634
|
+
agent_half_pin = ?, settings = ?
|
|
597
635
|
WHERE name = ?`;
|
|
598
636
|
|
|
599
637
|
/**
|
|
600
638
|
* Rewrite an installed plugin's row from a manifest (an update, or its
|
|
601
639
|
* reversal) with the agent half pinned for that version (null when Lamaste
|
|
602
|
-
* installs none). Status and install time stay.
|
|
640
|
+
* installs none). Status and install time stay. When the settings schema
|
|
641
|
+
* changes, the relay's cache of the settings is pruned in the same
|
|
642
|
+
* transaction — values and secrets whose field changed type or went
|
|
643
|
+
* (`prunedSettingsCache`) — so no read pairs the new schema with values
|
|
644
|
+
* stored for the old one: a value whose field became a secret never shows
|
|
645
|
+
* beside it, and a secret whose field became a plain value is no longer
|
|
646
|
+
* reported set.
|
|
603
647
|
*/
|
|
604
648
|
export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
|
|
605
649
|
const s = await getStmts();
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
650
|
+
const { prunedSettingsCache } = await import('./plugin-settings.js');
|
|
651
|
+
s.begin.run();
|
|
652
|
+
try {
|
|
653
|
+
const before = s.selectByName.get(name);
|
|
654
|
+
s.db
|
|
655
|
+
.prepare(updateRowSql)
|
|
656
|
+
.run(
|
|
657
|
+
nullableString(manifest.displayName),
|
|
658
|
+
manifest.version,
|
|
659
|
+
nullableString(manifest.description),
|
|
660
|
+
JSON.stringify(manifest.capabilities ?? []),
|
|
661
|
+
nullableJson(manifest.packages),
|
|
662
|
+
nullableJson(manifest.panel),
|
|
663
|
+
nullableJson(manifest.config),
|
|
664
|
+
nullableJson(manifest.modes),
|
|
665
|
+
manifest.hosting ?? DEFAULT_PLUGIN_HOSTING,
|
|
666
|
+
nullableJson(manifest.agent),
|
|
667
|
+
nullableJson(manifest.hooks),
|
|
668
|
+
nullableString(hooksEntrySha256),
|
|
669
|
+
nullableJson(agentHalfPin),
|
|
670
|
+
nullableJson(manifest.settings),
|
|
671
|
+
name,
|
|
672
|
+
);
|
|
673
|
+
const pruned = prunedSettingsCache(
|
|
674
|
+
s.selectSettingsByName.get(name),
|
|
675
|
+
before?.settings != null ? JSON.parse(before.settings) : null,
|
|
676
|
+
manifest.settings ?? null,
|
|
623
677
|
);
|
|
678
|
+
if (pruned?.delete) {
|
|
679
|
+
s.deleteSettingsByName.run(name);
|
|
680
|
+
} else if (pruned) {
|
|
681
|
+
s.updatePrunedSettings.run(
|
|
682
|
+
JSON.stringify(pruned.values),
|
|
683
|
+
JSON.stringify(pruned.secretsSet),
|
|
684
|
+
pruned.configured ? 1 : 0,
|
|
685
|
+
JSON.stringify(pruned.missing),
|
|
686
|
+
name,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
s.commit.run();
|
|
690
|
+
} catch (err) {
|
|
691
|
+
s.rollback.run();
|
|
692
|
+
throw err;
|
|
693
|
+
}
|
|
624
694
|
}
|
|
625
695
|
|
|
626
696
|
/**
|
|
@@ -637,13 +707,18 @@ export function replacePluginPackage(name, packageName, version, logger) {
|
|
|
637
707
|
assertSafeLamalibrePackage(spec);
|
|
638
708
|
logger?.info({ name, spec }, 'Installing plugin package version');
|
|
639
709
|
try {
|
|
640
|
-
await
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
710
|
+
await withRegistryUserconfig(
|
|
711
|
+
(env) =>
|
|
712
|
+
execa('npm', ['install', '--ignore-scripts', spec], {
|
|
713
|
+
cwd: STATE_DIR,
|
|
714
|
+
timeout: 120000,
|
|
715
|
+
env,
|
|
716
|
+
}),
|
|
717
|
+
{ log: logger },
|
|
718
|
+
);
|
|
644
719
|
} catch (err) {
|
|
645
720
|
logger?.error({ spec, stderr: err.stderr, err: err.message }, 'npm install failed');
|
|
646
|
-
throw Object.assign(new Error(
|
|
721
|
+
throw Object.assign(new Error(installFailure(spec, err)), { statusCode: 502 });
|
|
647
722
|
}
|
|
648
723
|
const require = createRequire(`${STATE_DIR}/`);
|
|
649
724
|
const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* install within the hour and limited to ten new tokens per agent per
|
|
13
13
|
* hour.
|
|
14
14
|
*
|
|
15
|
+
* The panel's own npm calls — installing a plugin, staging an update with
|
|
16
|
+
* `npm pack`, pinning an agent half with `npm view` — go through
|
|
17
|
+
* {@link withRegistryUserconfig}: with a credential configured, npm is
|
|
18
|
+
* routed to its registry for its scope and authenticated with the service
|
|
19
|
+
* token through a throw-away userconfig; without one, npm runs as it is
|
|
20
|
+
* configured on the machine (the public registry by default).
|
|
21
|
+
*
|
|
15
22
|
* Also the one piece of policy that belongs here: when a user loses a
|
|
16
23
|
* `feria-*` group or is deleted, their registry tokens are revoked (best
|
|
17
24
|
* effort, never blocks the user operation).
|
|
@@ -26,6 +33,7 @@ import {
|
|
|
26
33
|
revokeRegistryTokensForOwner,
|
|
27
34
|
lostFeriaGroups,
|
|
28
35
|
readOidcClientSecret,
|
|
36
|
+
withRegistryUserconfig as withRegistryUserconfigCore,
|
|
29
37
|
RegistryServiceTokenCache,
|
|
30
38
|
AgentTokenBroker,
|
|
31
39
|
RegistryCredentialError,
|
|
@@ -102,6 +110,35 @@ async function serviceToken(config) {
|
|
|
102
110
|
return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Run `fn(env)` — one npm call of the panel's own — against the configured
|
|
115
|
+
* registry: with a credential, `env` names an isolated userconfig (0600,
|
|
116
|
+
* removed afterwards) routing the credential's scope to its registry with
|
|
117
|
+
* the panel's service token; a `401` refreshes the token once and retries.
|
|
118
|
+
* Without a credential `fn({})` runs npm as the machine configures it.
|
|
119
|
+
* A credential file that cannot be read fails the call rather than
|
|
120
|
+
* silently installing from the public registry.
|
|
121
|
+
*
|
|
122
|
+
* @template T
|
|
123
|
+
* @param {(env: Record<string, string>) => Promise<T>} fn
|
|
124
|
+
* @param {{ log?: import('pino').Logger | undefined }} [options]
|
|
125
|
+
* @returns {Promise<T>}
|
|
126
|
+
*/
|
|
127
|
+
export async function withRegistryUserconfig(fn, options = {}) {
|
|
128
|
+
const config = await readRegistryCredentialConfig();
|
|
129
|
+
if (!config) return fn({});
|
|
130
|
+
const deps = await oidcDeps(config);
|
|
131
|
+
return withRegistryUserconfigCore(
|
|
132
|
+
config,
|
|
133
|
+
async ({ refresh }) => {
|
|
134
|
+
if (refresh) serviceTokens.invalidate();
|
|
135
|
+
return (await serviceTokens.resolve(config, deps)).token;
|
|
136
|
+
},
|
|
137
|
+
fn,
|
|
138
|
+
{ log: options.log },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
105
142
|
/**
|
|
106
143
|
* The calling agent's registry token: the cached one when it is still good
|
|
107
144
|
* for an install, otherwise a freshly brokered one-hour token — unless the
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* up, or after a restart of the panel. The agent side of the pipeline runs
|
|
10
10
|
* on the agent (its sync follows the relay's view of the tunnel).
|
|
11
11
|
*
|
|
12
|
+
* Whenever the plugin comes up — after a `down`, or first seen up — the
|
|
13
|
+
* relay also re-reads its settings from the agent half: they may have
|
|
14
|
+
* changed while the relay could not see it (a restore, a retyped field
|
|
15
|
+
* dropped by a new version).
|
|
16
|
+
*
|
|
12
17
|
* Calls for one plugin are serialised and run beside the monitor, which
|
|
13
18
|
* does not wait for them.
|
|
14
19
|
*/
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
} from './plugin-hooks.js';
|
|
22
27
|
import { runRelayHook } from './relay-hooks.js';
|
|
23
28
|
import { rememberTunnelFallback } from './request-hooks.js';
|
|
29
|
+
import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
|
|
24
30
|
|
|
25
31
|
const chains = new Map();
|
|
26
32
|
|
|
@@ -34,6 +40,11 @@ export function onHealthTransition(plugin, before, after, logger) {
|
|
|
34
40
|
.then(() => runTunnelHook(plugin, before, after, logger))
|
|
35
41
|
.catch((err) => {
|
|
36
42
|
logger?.error({ plugin, err: err?.message }, 'Tunnel-state hook pipeline failed');
|
|
43
|
+
})
|
|
44
|
+
.then(async () => {
|
|
45
|
+
if (after.state === 'up' && before?.state !== 'up') {
|
|
46
|
+
await refreshPluginSettingsBestEffort(plugin, logger);
|
|
47
|
+
}
|
|
37
48
|
});
|
|
38
49
|
chains.set(plugin, next);
|
|
39
50
|
next.finally(() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
|
|
2
|
+
import { AGENT_HALF_SETTINGS_MAX_BYTES, AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
|
|
3
3
|
import {
|
|
4
4
|
readPlugins,
|
|
5
5
|
installPlugin,
|
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
listPluginOperations,
|
|
36
36
|
takeOperationReveal,
|
|
37
37
|
} from '../../lib/plugin-operations.js';
|
|
38
|
+
import {
|
|
39
|
+
attachSettingsSummaries,
|
|
40
|
+
getPluginSettingsView,
|
|
41
|
+
updatePluginSettings,
|
|
42
|
+
} from '../../lib/plugin-settings.js';
|
|
38
43
|
import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
|
|
39
44
|
import { recordAudit } from '../../lib/audit.js';
|
|
40
45
|
import {
|
|
@@ -112,13 +117,57 @@ function operationErrorBody(err, fallback) {
|
|
|
112
117
|
};
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The error body of a refused settings read or change: the message and what
|
|
122
|
+
* a client acts on — `code` (`no_settings`, `operation_running` with the
|
|
123
|
+
* operation's id, `hosting_offline` with the `hosting`, `revision_mismatch`
|
|
124
|
+
* with the current `revision`, `schema_mismatch`, `settings_unsupported`,
|
|
125
|
+
* `refused`, `timeout`, …) and `errors` per field (400: the panel's check,
|
|
126
|
+
* 422: the agent half's). Built from what the settings library put on the
|
|
127
|
+
* error — never from the request body.
|
|
128
|
+
*/
|
|
129
|
+
function settingsErrorBody(err, fallback) {
|
|
130
|
+
// Only the settings library's own refusals carry a status; anything else
|
|
131
|
+
// is answered without its message.
|
|
132
|
+
if (!err.statusCode) return { error: fallback };
|
|
133
|
+
return {
|
|
134
|
+
error: err.message,
|
|
135
|
+
...(err.code ? { code: err.code } : {}),
|
|
136
|
+
...(err.errors ? { errors: err.errors } : {}),
|
|
137
|
+
...(Number.isInteger(err.revision) ? { revision: err.revision } : {}),
|
|
138
|
+
...(err.operationId ? { operationId: err.operationId } : {}),
|
|
139
|
+
...(err.hosting !== undefined ? { hosting: err.hosting } : {}),
|
|
140
|
+
// The agent half's own status, when it refused with an unexpected one.
|
|
141
|
+
...(Number.isInteger(err.status) ? { status: err.status } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
const OperationParamSchema = z.object({
|
|
116
146
|
id: z.string().uuid(),
|
|
117
147
|
});
|
|
118
148
|
|
|
149
|
+
/**
|
|
150
|
+
* `digest`: the approval digest that was shown — the installed version's or
|
|
151
|
+
* a pending update's. `version`: a pending update's version, which approves
|
|
152
|
+
* that update (with its digest, null when it brings neither relay hooks nor
|
|
153
|
+
* an agent half).
|
|
154
|
+
*/
|
|
119
155
|
const ApproveHooksBodySchema = z
|
|
120
|
-
.object({
|
|
121
|
-
|
|
156
|
+
.object({
|
|
157
|
+
digest: z
|
|
158
|
+
.string()
|
|
159
|
+
.regex(/^[a-f0-9]{64}$/, 'digest is a SHA-256 hex string')
|
|
160
|
+
.nullable()
|
|
161
|
+
.optional(),
|
|
162
|
+
version: z
|
|
163
|
+
.string()
|
|
164
|
+
.regex(/^[0-9A-Za-z.+-]{1,64}$/, 'version is a plugin version')
|
|
165
|
+
.optional(),
|
|
166
|
+
})
|
|
167
|
+
.strict()
|
|
168
|
+
.refine((body) => (body.digest ?? null) !== null || body.version !== undefined, {
|
|
169
|
+
message: 'digest or version is required',
|
|
170
|
+
});
|
|
122
171
|
|
|
123
172
|
const AutoApproveBodySchema = z.object({ enabled: z.boolean() }).strict();
|
|
124
173
|
|
|
@@ -281,7 +330,10 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
281
330
|
// `retainedData`: what agents keep of its agent half after it was
|
|
282
331
|
// removed without purge. `hooks`: its lifecycle hooks and their
|
|
283
332
|
// approval (see GET /plugins/:name/hooks).
|
|
284
|
-
|
|
333
|
+
// `settings`: for a plugin that declares settings, whether it still
|
|
334
|
+
// needs configuration — { configured, missing, syncedAt }, no values
|
|
335
|
+
// (see GET /plugins/:name/settings).
|
|
336
|
+
const plugins = await attachSettingsSummaries(await attachHostings(registry.plugins));
|
|
285
337
|
return {
|
|
286
338
|
plugins: await Promise.all(plugins.map(withHooks)),
|
|
287
339
|
// Every agent's retained data — of uninstalled plugins too, which
|
|
@@ -308,7 +360,7 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
308
360
|
return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
|
|
309
361
|
}
|
|
310
362
|
|
|
311
|
-
const [withHosting] = await attachHostings([plugin]);
|
|
363
|
+
const [withHosting] = await attachSettingsSummaries(await attachHostings([plugin]));
|
|
312
364
|
return { plugin: await withHooks(withHosting) };
|
|
313
365
|
},
|
|
314
366
|
);
|
|
@@ -538,6 +590,89 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
538
590
|
},
|
|
539
591
|
);
|
|
540
592
|
|
|
593
|
+
// ===========================================================================
|
|
594
|
+
// Settings of an agent-hosted plugin (kept by its agent half)
|
|
595
|
+
// ===========================================================================
|
|
596
|
+
|
|
597
|
+
// ------------------------------------------------------------------
|
|
598
|
+
// GET /plugins/:name/settings — { schemaVersion, fields, values, configured,
|
|
599
|
+
// missing, revision, source, syncedAt, hosting }: each non-secret field's
|
|
600
|
+
// effective value, each secret as { set: true, setAt } or { set: false }
|
|
601
|
+
// (never a value). Read from the agent half when its hosting is active
|
|
602
|
+
// and not down (`source: live`), else from the relay's cache (`cache`;
|
|
603
|
+
// `none` when never read). 404 `no_settings` for a plugin without them.
|
|
604
|
+
// ------------------------------------------------------------------
|
|
605
|
+
fastify.get(
|
|
606
|
+
'/plugins/:name/settings',
|
|
607
|
+
{
|
|
608
|
+
preHandler: fastify.requireRole(['admin']),
|
|
609
|
+
},
|
|
610
|
+
async (request, reply) => {
|
|
611
|
+
const params = PluginNameParamSchema.parse(request.params);
|
|
612
|
+
try {
|
|
613
|
+
return await getPluginSettingsView(params.name, { logger: request.log });
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (!err.statusCode) {
|
|
616
|
+
request.log.error(
|
|
617
|
+
{ plugin: params.name, err: err.message },
|
|
618
|
+
'Failed to read the plugin settings',
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return reply
|
|
622
|
+
.code(err.statusCode || 500)
|
|
623
|
+
.send(settingsErrorBody(err, 'Failed to read the plugin settings'));
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
// ------------------------------------------------------------------
|
|
629
|
+
// PUT /plugins/:name/settings { revision, values?, secrets? } — a partial
|
|
630
|
+
// change (`null` clears an optional field), sent through the tunnel to
|
|
631
|
+
// the agent half, which stores it: 200 with the settings as above. 400
|
|
632
|
+
// `errors` (checked here), 409 (`operation_running`, the agent half's
|
|
633
|
+
// `revision_mismatch` with `revision`, `schema_mismatch`), 422 `errors`
|
|
634
|
+
// (the agent half's check, normalized), 503 `hosting_offline` with
|
|
635
|
+
// `hosting`, 502 `settings_unsupported` / `refused` / `unauthenticated`
|
|
636
|
+
// (an answer without the agent half's responder signature), 504 `timeout`.
|
|
637
|
+
//
|
|
638
|
+
// The body may carry secrets. It is handed to the settings library once
|
|
639
|
+
// and dropped from the request; nothing here logs or repeats it. The
|
|
640
|
+
// library seals each secret to the one call that carries it through the
|
|
641
|
+
// tunnel.
|
|
642
|
+
// ------------------------------------------------------------------
|
|
643
|
+
fastify.put(
|
|
644
|
+
'/plugins/:name/settings',
|
|
645
|
+
{
|
|
646
|
+
preHandler: fastify.requireRole(['admin']),
|
|
647
|
+
bodyLimit: AGENT_HALF_SETTINGS_MAX_BYTES,
|
|
648
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
649
|
+
},
|
|
650
|
+
async (request, reply) => {
|
|
651
|
+
const body = request.body;
|
|
652
|
+
request.body = undefined;
|
|
653
|
+
const params = PluginNameParamSchema.parse(request.params);
|
|
654
|
+
try {
|
|
655
|
+
return await updatePluginSettings(params.name, body, {
|
|
656
|
+
actor: actorFromRequest(request),
|
|
657
|
+
certSerial: certSerialFromRequest(request),
|
|
658
|
+
logger: request.log,
|
|
659
|
+
});
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if (!err.statusCode) {
|
|
662
|
+
// A failure of Lamaste's own (state.db, a malformed hosting record):
|
|
663
|
+
// its message names no value of the change.
|
|
664
|
+
request.log.error(
|
|
665
|
+
{ plugin: params.name, err: err.message },
|
|
666
|
+
'Failed to change the plugin settings',
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return reply
|
|
670
|
+
.code(err.statusCode || 500)
|
|
671
|
+
.send(settingsErrorBody(err, 'Failed to change the plugin settings'));
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
541
676
|
// ===========================================================================
|
|
542
677
|
// Lifecycle hooks: review, approval, auto-approve
|
|
543
678
|
// ===========================================================================
|
|
@@ -547,7 +682,8 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
547
682
|
// fail-open flags, effects, digest), the agent hooks, the agent half
|
|
548
683
|
// Lamaste installs (package, version, integrity, service, system
|
|
549
684
|
// packages), the approval (`approval.digest` is what to approve) and a
|
|
550
|
-
// pending update
|
|
685
|
+
// pending update — its relay hook set and agent half, its settings
|
|
686
|
+
// schema diff, why it waits (`approvalRequired`)
|
|
551
687
|
// ------------------------------------------------------------------
|
|
552
688
|
fastify.get(
|
|
553
689
|
'/plugins/:name/hooks',
|
|
@@ -563,10 +699,12 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
563
699
|
);
|
|
564
700
|
|
|
565
701
|
// ------------------------------------------------------------------
|
|
566
|
-
// POST /plugins/:name/hooks/approve — approve what
|
|
567
|
-
// (relay hooks, an agent half Lamaste installs) with
|
|
568
|
-
// shown. Approving a pending update
|
|
569
|
-
//
|
|
702
|
+
// POST /plugins/:name/hooks/approve { digest?, version? } — approve what
|
|
703
|
+
// the plugin brings (relay hooks, an agent half Lamaste installs) with
|
|
704
|
+
// the digest that was shown. Approving a pending update — by its digest,
|
|
705
|
+
// or by its version (and digest, when it has one) — approves everything
|
|
706
|
+
// it waits for (`approvalRequired`) and applies it (202 with its
|
|
707
|
+
// operation).
|
|
570
708
|
// ------------------------------------------------------------------
|
|
571
709
|
fastify.post(
|
|
572
710
|
'/plugins/:name/hooks/approve',
|
|
@@ -582,18 +720,23 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
582
720
|
const actor = actorFromRequest(request);
|
|
583
721
|
const certSerial = certSerialFromRequest(request);
|
|
584
722
|
try {
|
|
585
|
-
const approved = await approveHookSet(plugin, body.digest, {
|
|
723
|
+
const approved = await approveHookSet(plugin, body.digest ?? null, {
|
|
586
724
|
actor,
|
|
587
725
|
certSerial,
|
|
588
726
|
logger: request.log,
|
|
727
|
+
version: body.version ?? null,
|
|
589
728
|
});
|
|
590
729
|
if (approved.target === 'update') {
|
|
591
|
-
|
|
592
|
-
const result = await updatePlugin(plugin.name,
|
|
730
|
+
// The update as it was approved: fetched again, it must still be that.
|
|
731
|
+
const result = await updatePlugin(plugin.name, approved.version, {
|
|
593
732
|
actor,
|
|
594
733
|
certSerial,
|
|
595
734
|
logger: request.log,
|
|
596
|
-
|
|
735
|
+
approvedUpdate: {
|
|
736
|
+
version: approved.version,
|
|
737
|
+
digest: approved.digest,
|
|
738
|
+
secretToPlain: approved.secretToPlain,
|
|
739
|
+
},
|
|
597
740
|
sourceIp: request.ip,
|
|
598
741
|
});
|
|
599
742
|
return reply.code(202).send({ ok: true, ...result });
|
|
@@ -603,7 +746,8 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
603
746
|
const statusCode = err.statusCode || 500;
|
|
604
747
|
return reply.code(statusCode).send({
|
|
605
748
|
...operationErrorBody(err, 'Failed to approve the hook set'),
|
|
606
|
-
|
|
749
|
+
// The digest to review now: null when what waits brings nothing to approve by one.
|
|
750
|
+
...(err.currentDigest !== undefined ? { currentDigest: err.currentDigest } : {}),
|
|
607
751
|
});
|
|
608
752
|
}
|
|
609
753
|
},
|
|
@@ -638,9 +782,11 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
638
782
|
// ------------------------------------------------------------------
|
|
639
783
|
// POST /plugins/:name/update { version? } — fetch the version (default
|
|
640
784
|
// latest) and read it without installing it; pin its agent half. A
|
|
641
|
-
// changed relay hook set or agent half
|
|
642
|
-
//
|
|
643
|
-
// (202 {
|
|
785
|
+
// changed relay hook set or agent half (unless auto-approved), and a
|
|
786
|
+
// secret setting turned plain (always), wait for approval
|
|
787
|
+
// (202 { pendingUpdate }, its `approvalRequired` says why); otherwise the
|
|
788
|
+
// update runs as an operation (202 { operation }). 200 { upToDate } when
|
|
789
|
+
// there is nothing to do.
|
|
644
790
|
// ------------------------------------------------------------------
|
|
645
791
|
fastify.post(
|
|
646
792
|
'/plugins/:name/update',
|
|
@@ -716,7 +862,8 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
716
862
|
details: { packageName: plugin.packageName, version: plugin.version },
|
|
717
863
|
});
|
|
718
864
|
// `hooks`: the relay hook set to review (and approve) before hosting.
|
|
719
|
-
|
|
865
|
+
const [listed] = await attachSettingsSummaries([plugin]);
|
|
866
|
+
return { ok: true, plugin: listed, oidcClients, hooks: await hookStatus(plugin) };
|
|
720
867
|
} catch (err) {
|
|
721
868
|
const statusCode = err.statusCode || 500;
|
|
722
869
|
// `route_taken`: another installed plugin holds the route (`route`, `owner`).
|
|
@@ -24,11 +24,12 @@ import {
|
|
|
24
24
|
} from '@lamalibre/lamaste';
|
|
25
25
|
import { getConfig } from '../../lib/config.js';
|
|
26
26
|
import { readTunnels, readSites } from '../../lib/state.js';
|
|
27
|
-
import { loadAgentRegistry } from '../../lib/mtls.js';
|
|
27
|
+
import { getAgentHostFactsHash, loadAgentRegistry } from '../../lib/mtls.js';
|
|
28
28
|
import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
|
|
29
29
|
import { buildChiselArgs } from '../../lib/chisel-args.js';
|
|
30
30
|
import { listAgentPluginAssignments } from '../../lib/plugin-hosting.js';
|
|
31
31
|
import { pendingAgentJobs } from '../../lib/agent-jobs.js';
|
|
32
|
+
import { recordAgentContact } from '../../lib/agent-presence.js';
|
|
32
33
|
import {
|
|
33
34
|
tunnelNginxDeps,
|
|
34
35
|
tunnelCertbotDeps,
|
|
@@ -236,6 +237,17 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
236
237
|
// Parsed outside the try so a malformed ?agent= is a 400 from the
|
|
237
238
|
// shared Zod error handler, not a 500 from the catch below.
|
|
238
239
|
const { agent } = AgentConfigQuerySchema.parse(request.query);
|
|
240
|
+
// An agent's own call (its sync, every 30 s) is its contact with the
|
|
241
|
+
// relay — what the panel shows as when a hosting agent last synced.
|
|
242
|
+
// An admin naming an agent with ?agent= is not that agent's contact.
|
|
243
|
+
const caller = confinedLabel(request);
|
|
244
|
+
if (caller !== null && AGENT_LABEL_REGEX.test(caller)) {
|
|
245
|
+
try {
|
|
246
|
+
await recordAgentContact(caller);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
request.log.warn({ err: err.message, agent: caller }, 'Failed to record agent contact');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
239
251
|
try {
|
|
240
252
|
const config = getConfig();
|
|
241
253
|
const tunnels = await readTunnels();
|
|
@@ -285,6 +297,14 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
285
297
|
// through its app host helper (`app-install`, `app-activate`,
|
|
286
298
|
// `app-remove`) — see lib/agent-jobs.js for each job's fields.
|
|
287
299
|
agentJobs: AGENT_LABEL_REGEX.test(label) ? await pendingAgentJobs(label) : [],
|
|
300
|
+
// For the agent's own sync: the hash of the host facts stored on
|
|
301
|
+
// its record (null: none), which it compares with its own and
|
|
302
|
+
// reports them again when they differ — a record replaced since its
|
|
303
|
+
// last report (enrolled again on the label, a restored backup)
|
|
304
|
+
// holds none. Not for an admin's ?agent= call.
|
|
305
|
+
...(caller !== null && AGENT_LABEL_REGEX.test(caller)
|
|
306
|
+
? { hostFactsHash: await getAgentHostFactsHash(caller) }
|
|
307
|
+
: {}),
|
|
288
308
|
};
|
|
289
309
|
} catch (err) {
|
|
290
310
|
request.log.error(err, 'Failed to generate agent config');
|