@lamalibre/create-lamaste 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +4 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/relay-services.js +15 -1
- package/src/lib/service-config.js +113 -0
- package/src/tasks/gatekeeper.js +8 -3
- package/src/tasks/node.js +34 -75
- package/src/tasks/redeploy.js +36 -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/authentication.md +6 -3
- package/vendor/docs/01-concepts/authorization.md +29 -17
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
- package/vendor/docs/01-concepts/security-model.md +98 -34
- 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 +34 -16
- 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 +10 -10
- 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/nginx-configuration.md +91 -26
- package/vendor/docs/03-architecture/overview.md +8 -8
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- 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 +407 -0
- package/vendor/docs/04-api-reference/app-host.md +360 -0
- package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
- package/vendor/docs/04-api-reference/certificates.md +95 -38
- package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -111
- package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +25 -20
- package/vendor/docs/06-reference/config-files.md +190 -97
- 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 +33 -8
- package/vendor/docs/_index.json +20 -0
- package/vendor/docs/gatekeeper-design.md +22 -19
- package/vendor/docs/identity-directory-design.md +57 -56
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +47 -8
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +4 -3
- package/vendor/server/bin/lamaste-server.js +18 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/plugins.js +963 -12
- package/vendor/server/src/commands/push-install.js +317 -0
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/uninstall.js +28 -1
- package/vendor/server/src/ndjson.js +4 -2
- package/vendor/server/src/panel-api.js +32 -4
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.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 +56 -4
- package/vendor/serverd/src/lib/actor.js +24 -0
- package/vendor/serverd/src/lib/agent-jobs.js +330 -0
- package/vendor/serverd/src/lib/app-error.js +10 -0
- package/vendor/serverd/src/lib/audit.js +145 -0
- package/vendor/serverd/src/lib/hook-effects.js +123 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
- package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
- package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
- package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
- package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +89 -8
- package/vendor/serverd/src/lib/nginx.js +298 -44
- package/vendor/serverd/src/lib/notifications.js +107 -0
- package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
- package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
- package/vendor/serverd/src/lib/plugin-operations.js +295 -0
- package/vendor/serverd/src/lib/plugin-packages.js +249 -0
- package/vendor/serverd/src/lib/plugins.js +298 -68
- package/vendor/serverd/src/lib/push-install.js +88 -2
- package/vendor/serverd/src/lib/relay-hooks.js +100 -0
- package/vendor/serverd/src/lib/relay-signing.js +43 -0
- package/vendor/serverd/src/lib/request-hooks.js +79 -0
- package/vendor/serverd/src/lib/retained-data.js +99 -0
- package/vendor/serverd/src/lib/trust-proxy.js +34 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
- package/vendor/serverd/src/middleware/twofa-session.js +11 -1
- package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
- package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
- package/vendor/serverd/src/routes/management/agents.js +262 -1
- package/vendor/serverd/src/routes/management/audit.js +81 -0
- package/vendor/serverd/src/routes/management/certs.js +1 -15
- package/vendor/serverd/src/routes/management/plugins.js +569 -54
- package/vendor/serverd/src/routes/management/tunnels.js +26 -0
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
|
@@ -11,22 +11,28 @@
|
|
|
11
11
|
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { z } from 'zod';
|
|
15
14
|
import { execa } from 'execa';
|
|
16
15
|
import {
|
|
17
16
|
DEFAULT_PLUGIN_HOSTING,
|
|
17
|
+
ManifestValidationError,
|
|
18
18
|
RESERVED_API_PREFIXES,
|
|
19
19
|
RESERVED_NAV_LABELS,
|
|
20
20
|
derivePluginRoute,
|
|
21
|
+
hasAgentHalf,
|
|
22
|
+
pluginHostCapability,
|
|
21
23
|
validateManifest as coreValidateManifest,
|
|
22
24
|
} from '@lamalibre/lamaste';
|
|
23
|
-
import { revokePluginCapabilitiesFromAgents } from '@lamalibre/lamaste/server';
|
|
24
25
|
import { listRegisteredSubScopeNames } from './tickets.js';
|
|
25
26
|
import { getStateDb } from './state-db.js';
|
|
27
|
+
import { revokePluginCapabilitiesFromAgents } from './mtls.js';
|
|
28
|
+
import {
|
|
29
|
+
bareLamalibrePackageName,
|
|
30
|
+
installedHookEntrySha256,
|
|
31
|
+
pinAgentHalf,
|
|
32
|
+
} from './plugin-packages.js';
|
|
26
33
|
|
|
27
34
|
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
28
35
|
const PLUGINS_DIR = path.join(STATE_DIR, 'plugins');
|
|
29
|
-
const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
|
|
30
36
|
|
|
31
37
|
// Promise-chain mutex. SQLite's BEGIN IMMEDIATE serialises a single write
|
|
32
38
|
// transaction, but install/uninstall are read-modify-write sequences that
|
|
@@ -57,12 +63,14 @@ async function getStmts() {
|
|
|
57
63
|
|
|
58
64
|
selectAll: db.prepare('SELECT * FROM plugins'),
|
|
59
65
|
selectByName: db.prepare('SELECT * FROM plugins WHERE name = ?'),
|
|
66
|
+
selectByRoute: db.prepare('SELECT * FROM plugins WHERE route = ?'),
|
|
60
67
|
selectByPackageName: db.prepare('SELECT * FROM plugins WHERE package_name = ?'),
|
|
61
68
|
insert: db.prepare(`
|
|
62
69
|
INSERT INTO plugins
|
|
63
|
-
(name, display_name, package_name, version, description, capabilities,
|
|
64
|
-
packages, panel, config, modes, hosting,
|
|
65
|
-
|
|
70
|
+
(name, route, display_name, package_name, version, description, capabilities,
|
|
71
|
+
packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
|
|
72
|
+
agent_half_pin, status, installed_at, enabled_at)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
66
74
|
`),
|
|
67
75
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
68
76
|
deleteAll: db.prepare('DELETE FROM plugins'),
|
|
@@ -90,11 +98,16 @@ async function getStmts() {
|
|
|
90
98
|
// missing keys are absent rather than null). `hosting` is plain TEXT
|
|
91
99
|
// ('server' | 'agent'; rows from before 4.2 default to 'server'): an
|
|
92
100
|
// agent-hosted plugin is installed (npm, manifest) like any other, but the
|
|
93
|
-
// plugin host never imports it — see `pluginHostPlugin`.
|
|
101
|
+
// plugin host never imports it — see `pluginHostPlugin`. `route` is the
|
|
102
|
+
// plugin's route (`derivePluginRoute(name)`), unique among installed
|
|
103
|
+
// plugins; null only for a plugin installed before routes were unique that
|
|
104
|
+
// shares its route with an older one (migration 0010) — such a plugin is
|
|
105
|
+
// never hosted and its uninstall leaves the route's capabilities alone.
|
|
94
106
|
|
|
95
107
|
function rowToPlugin(row) {
|
|
96
108
|
const entry = {
|
|
97
109
|
name: row.name,
|
|
110
|
+
route: row.route ?? null,
|
|
98
111
|
packageName: row.package_name,
|
|
99
112
|
version: row.version,
|
|
100
113
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
|
|
@@ -108,6 +121,10 @@ function rowToPlugin(row) {
|
|
|
108
121
|
if (row.panel != null) entry.panel = JSON.parse(row.panel);
|
|
109
122
|
if (row.config != null) entry.config = JSON.parse(row.config);
|
|
110
123
|
if (row.modes != null) entry.modes = JSON.parse(row.modes);
|
|
124
|
+
if (row.agent != null) entry.agent = JSON.parse(row.agent);
|
|
125
|
+
if (row.hooks != null) entry.hooks = JSON.parse(row.hooks);
|
|
126
|
+
if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
|
|
127
|
+
if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
|
|
111
128
|
if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
|
|
112
129
|
return entry;
|
|
113
130
|
}
|
|
@@ -151,6 +168,7 @@ export async function writePlugins(data) {
|
|
|
151
168
|
for (const p of list) {
|
|
152
169
|
s.insert.run(
|
|
153
170
|
p.name,
|
|
171
|
+
p.route === undefined ? derivePluginRoute(p.name) : p.route,
|
|
154
172
|
nullableString(p.displayName),
|
|
155
173
|
p.packageName,
|
|
156
174
|
p.version,
|
|
@@ -161,6 +179,10 @@ export async function writePlugins(data) {
|
|
|
161
179
|
nullableJson(p.config),
|
|
162
180
|
nullableJson(p.modes),
|
|
163
181
|
p.hosting ?? DEFAULT_PLUGIN_HOSTING,
|
|
182
|
+
nullableJson(p.agent),
|
|
183
|
+
nullableJson(p.hooks),
|
|
184
|
+
nullableString(p.hooksEntrySha256),
|
|
185
|
+
nullableJson(p.agentHalfPin),
|
|
164
186
|
p.status,
|
|
165
187
|
p.installedAt,
|
|
166
188
|
nullableString(p.enabledAt),
|
|
@@ -179,7 +201,7 @@ export async function writePlugins(data) {
|
|
|
179
201
|
*
|
|
180
202
|
* @param {object} raw - The raw manifest object
|
|
181
203
|
* @returns {object} The validated and parsed manifest
|
|
182
|
-
* @throws {
|
|
204
|
+
* @throws {ManifestValidationError} If validation fails
|
|
183
205
|
*/
|
|
184
206
|
export function validateManifest(raw) {
|
|
185
207
|
return coreValidateManifest(raw);
|
|
@@ -236,10 +258,74 @@ async function syncOidcClientsBestEffort(logger) {
|
|
|
236
258
|
}
|
|
237
259
|
}
|
|
238
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The panel's rules for a manifest beyond its schema — checked at install
|
|
263
|
+
* and again for an update. Returns `{ message, statusCode }` or null.
|
|
264
|
+
*/
|
|
265
|
+
export async function manifestPolicyError(manifest) {
|
|
266
|
+
// Names that collide with core API route prefixes
|
|
267
|
+
if (RESERVED_API_PREFIXES.includes(manifest.name)) {
|
|
268
|
+
return { message: `Plugin name "${manifest.name}" is reserved`, statusCode: 400 };
|
|
269
|
+
}
|
|
270
|
+
// A displayName that matches a core navigation label
|
|
271
|
+
if (manifest.displayName && RESERVED_NAV_LABELS.includes(manifest.displayName.toLowerCase())) {
|
|
272
|
+
return {
|
|
273
|
+
message: `Plugin display name "${manifest.displayName}" conflicts with a core navigation label`,
|
|
274
|
+
statusCode: 400,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// A plugin with an agent half is named by its route: the route keys its
|
|
278
|
+
// hosting, its internal tunnel, its agent half's account and directories
|
|
279
|
+
// on the agent and its forwarded API (`/api/<route>`), while its hook
|
|
280
|
+
// state, operations and audit entries go by name — the two must be one.
|
|
281
|
+
if (hasAgentHalf(manifest)) {
|
|
282
|
+
const route = derivePluginRoute(manifest.name);
|
|
283
|
+
if (route !== manifest.name) {
|
|
284
|
+
return {
|
|
285
|
+
message: `A plugin with an agent half is named by its route: "${manifest.name}" would be served as "${route}" — name it "${route}" (its packages keep their names)`,
|
|
286
|
+
statusCode: 400,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// apiPrefix must match the plugin name
|
|
291
|
+
if (manifest.panel?.apiPrefix && manifest.panel.apiPrefix !== `/api/${manifest.name}`) {
|
|
292
|
+
return {
|
|
293
|
+
message: `Plugin apiPrefix must be "/api/${manifest.name}" but got "${manifest.panel.apiPrefix}"`,
|
|
294
|
+
statusCode: 400,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// Cross-namespace collision: any capability declared by the plugin must
|
|
298
|
+
// not match a sub-scope name already owned by a registered ticket scope.
|
|
299
|
+
// Schema-level namespacing puts both sides in `plugin:*`; this check is
|
|
300
|
+
// a flat set intersection. The reverse direction (registering a scope
|
|
301
|
+
// that collides with an installed plugin's cap) is enforced in
|
|
302
|
+
// `registerScope`.
|
|
303
|
+
if (Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0) {
|
|
304
|
+
const subScopeNames = new Set(await listRegisteredSubScopeNames());
|
|
305
|
+
const collision = manifest.capabilities.find((c) => subScopeNames.has(c));
|
|
306
|
+
if (collision) {
|
|
307
|
+
return {
|
|
308
|
+
message:
|
|
309
|
+
`Plugin capability "${collision}" collides with an existing ticket scope sub-scope name. ` +
|
|
310
|
+
'Pick a different action name or unregister the conflicting ticket scope first.',
|
|
311
|
+
statusCode: 409,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
239
318
|
export function installPlugin(packageName, logger) {
|
|
240
319
|
return withPluginLock(async () => {
|
|
241
320
|
// Validate package scope
|
|
242
321
|
assertSafeLamalibrePackage(packageName);
|
|
322
|
+
// `@lamalibre/x@1.2.3` installs that version; the registry knows the
|
|
323
|
+
// package by its bare name (the manifest is resolved by it, too).
|
|
324
|
+
const installSpec = packageName;
|
|
325
|
+
packageName = bareLamalibrePackageName(installSpec);
|
|
326
|
+
// Remove the package npm installed when it is refused below.
|
|
327
|
+
const discardPackage = () =>
|
|
328
|
+
execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
|
|
243
329
|
|
|
244
330
|
const s = await getStmts();
|
|
245
331
|
|
|
@@ -262,15 +348,18 @@ export function installPlugin(packageName, logger) {
|
|
|
262
348
|
}
|
|
263
349
|
|
|
264
350
|
// Install the npm package
|
|
265
|
-
logger.info({ packageName }, 'Installing plugin package');
|
|
351
|
+
logger.info({ packageName: installSpec }, 'Installing plugin package');
|
|
266
352
|
try {
|
|
267
|
-
await execa('npm', ['install', '--ignore-scripts',
|
|
353
|
+
await execa('npm', ['install', '--ignore-scripts', installSpec], {
|
|
268
354
|
cwd: STATE_DIR,
|
|
269
355
|
timeout: 120000,
|
|
270
356
|
});
|
|
271
357
|
} catch (err) {
|
|
272
|
-
logger.error(
|
|
273
|
-
|
|
358
|
+
logger.error(
|
|
359
|
+
{ packageName: installSpec, stderr: err.stderr, err: err.message },
|
|
360
|
+
'npm install failed',
|
|
361
|
+
);
|
|
362
|
+
throw Object.assign(new Error(`Failed to install "${installSpec}"`), { statusCode: 500 });
|
|
274
363
|
}
|
|
275
364
|
|
|
276
365
|
// Read the plugin manifest from the installed package
|
|
@@ -283,70 +372,58 @@ export function installPlugin(packageName, logger) {
|
|
|
283
372
|
} catch (err) {
|
|
284
373
|
// Clean up the installed package if manifest is missing/invalid
|
|
285
374
|
logger.warn({ packageName, err: err.message }, 'Invalid or missing manifest, uninstalling');
|
|
286
|
-
await
|
|
375
|
+
await discardPackage();
|
|
287
376
|
|
|
288
|
-
if (err instanceof
|
|
289
|
-
throw Object.assign(
|
|
290
|
-
new Error(`Invalid plugin manifest: ${err.errors.map((e) => e.message).join(', ')}`),
|
|
291
|
-
{ statusCode: 400 },
|
|
292
|
-
);
|
|
377
|
+
if (err instanceof ManifestValidationError) {
|
|
378
|
+
throw Object.assign(new Error(err.message), { statusCode: 400 });
|
|
293
379
|
}
|
|
294
380
|
throw Object.assign(new Error(`Plugin manifest not found in "${packageName}"`), {
|
|
295
381
|
statusCode: 400,
|
|
296
382
|
});
|
|
297
383
|
}
|
|
298
384
|
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
await
|
|
302
|
-
throw Object.assign(new Error(
|
|
303
|
-
statusCode: 400,
|
|
304
|
-
});
|
|
385
|
+
const policyError = await manifestPolicyError(manifest);
|
|
386
|
+
if (policyError) {
|
|
387
|
+
await discardPackage();
|
|
388
|
+
throw Object.assign(new Error(policyError.message), { statusCode: policyError.statusCode });
|
|
305
389
|
}
|
|
306
390
|
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
}
|
|
391
|
+
// A plugin with relay hooks of its own: its module must be in the
|
|
392
|
+
// package; its SHA-256 goes into the hook set's digest, which an admin
|
|
393
|
+
// approves before Lamaste runs it.
|
|
394
|
+
let hooksEntrySha256 = null;
|
|
395
|
+
try {
|
|
396
|
+
hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
await discardPackage();
|
|
399
|
+
throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
|
|
318
400
|
}
|
|
319
401
|
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
);
|
|
402
|
+
// An agent half Lamaste installs: its tarball's integrity is pinned now,
|
|
403
|
+
// from the registry this install used — the approval covers it and the
|
|
404
|
+
// agent installs only a tarball that matches it.
|
|
405
|
+
let agentHalfPin = null;
|
|
406
|
+
try {
|
|
407
|
+
agentHalfPin = await pinAgentHalf(manifest);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
await discardPackage();
|
|
410
|
+
throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
|
|
329
411
|
}
|
|
330
412
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
'Pick a different action name or unregister the conflicting ticket scope first.',
|
|
346
|
-
),
|
|
347
|
-
{ statusCode: 409 },
|
|
348
|
-
);
|
|
349
|
-
}
|
|
413
|
+
// A name another installed package already uses would fail the INSERT
|
|
414
|
+
// below and leave this package behind.
|
|
415
|
+
if (s.selectByName.get(manifest.name)) {
|
|
416
|
+
await discardPackage();
|
|
417
|
+
throw Object.assign(new Error(`A plugin named "${manifest.name}" is already installed`), {
|
|
418
|
+
statusCode: 409,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// So would its route (unique index): one installed plugin per route.
|
|
422
|
+
const route = derivePluginRoute(manifest.name);
|
|
423
|
+
const routeOwner = s.selectByRoute.get(route);
|
|
424
|
+
if (routeOwner) {
|
|
425
|
+
await discardPackage();
|
|
426
|
+
throw routeTakenError(manifest.name, route, routeOwner.name);
|
|
350
427
|
}
|
|
351
428
|
|
|
352
429
|
// Create the plugin state directory
|
|
@@ -356,6 +433,7 @@ export function installPlugin(packageName, logger) {
|
|
|
356
433
|
// Build the entry, then persist via INSERT
|
|
357
434
|
const entry = {
|
|
358
435
|
name: manifest.name,
|
|
436
|
+
route,
|
|
359
437
|
displayName: manifest.displayName,
|
|
360
438
|
packageName,
|
|
361
439
|
version: manifest.version,
|
|
@@ -366,6 +444,10 @@ export function installPlugin(packageName, logger) {
|
|
|
366
444
|
config: manifest.config,
|
|
367
445
|
modes: manifest.modes,
|
|
368
446
|
hosting: manifest.hosting,
|
|
447
|
+
agent: manifest.agent,
|
|
448
|
+
hooks: manifest.hooks,
|
|
449
|
+
hooksEntrySha256,
|
|
450
|
+
agentHalfPin,
|
|
369
451
|
status: 'disabled',
|
|
370
452
|
installedAt: new Date().toISOString(),
|
|
371
453
|
};
|
|
@@ -374,6 +456,7 @@ export function installPlugin(packageName, logger) {
|
|
|
374
456
|
try {
|
|
375
457
|
s.insert.run(
|
|
376
458
|
entry.name,
|
|
459
|
+
entry.route,
|
|
377
460
|
nullableString(entry.displayName),
|
|
378
461
|
entry.packageName,
|
|
379
462
|
entry.version,
|
|
@@ -384,6 +467,10 @@ export function installPlugin(packageName, logger) {
|
|
|
384
467
|
nullableJson(entry.config),
|
|
385
468
|
nullableJson(entry.modes),
|
|
386
469
|
entry.hosting,
|
|
470
|
+
nullableJson(entry.agent),
|
|
471
|
+
nullableJson(entry.hooks),
|
|
472
|
+
nullableString(entry.hooksEntrySha256),
|
|
473
|
+
nullableJson(entry.agentHalfPin),
|
|
387
474
|
entry.status,
|
|
388
475
|
entry.installedAt,
|
|
389
476
|
null,
|
|
@@ -424,6 +511,24 @@ export function uninstallPlugin(name, logger) {
|
|
|
424
511
|
});
|
|
425
512
|
}
|
|
426
513
|
|
|
514
|
+
// A hosted plugin is uninstalled by an operation that runs its
|
|
515
|
+
// uninstall pipeline first (plugin-lifecycle.js); by the time it gets
|
|
516
|
+
// here the hosting is gone.
|
|
517
|
+
if (hasAgentHalf(plugin)) {
|
|
518
|
+
const { isPluginHosted } = await import('./plugin-hosting.js');
|
|
519
|
+
if (await isPluginHosted(name)) {
|
|
520
|
+
throw Object.assign(
|
|
521
|
+
new Error(`Plugin "${name}" is hosted on an agent — its hosting is removed first`),
|
|
522
|
+
{ statusCode: 409 },
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Its relay hook runner and approval go with it: a later install of the
|
|
528
|
+
// plugin is approved afresh.
|
|
529
|
+
const { removeRelayHooks } = await import('./plugin-hooks.js');
|
|
530
|
+
await removeRelayHooks(name, logger);
|
|
531
|
+
|
|
427
532
|
// Uninstall the npm package
|
|
428
533
|
logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
|
|
429
534
|
await execa('npm', ['uninstall', plugin.packageName], {
|
|
@@ -450,12 +555,24 @@ export function uninstallPlugin(name, logger) {
|
|
|
450
555
|
|
|
451
556
|
// Sweep this plugin's capabilities off every agent so re-installing the
|
|
452
557
|
// plugin later does NOT silently re-grant. Failure here is non-fatal
|
|
453
|
-
// (the plugin is already gone), but logged loudly.
|
|
558
|
+
// (the plugin is already gone), but logged loudly. A plugin without a
|
|
559
|
+
// route of its own shares its namespace with the plugin that holds the
|
|
560
|
+
// route: the capabilities are that plugin's, and stay.
|
|
454
561
|
try {
|
|
455
|
-
const route =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
562
|
+
const route = plugin.route;
|
|
563
|
+
if (route === null) {
|
|
564
|
+
logger.warn(
|
|
565
|
+
{ name, route: derivePluginRoute(plugin.name) },
|
|
566
|
+
'Plugin shares its route with another installed plugin — its capabilities are left in place',
|
|
567
|
+
);
|
|
568
|
+
} else {
|
|
569
|
+
const diffs = await revokePluginCapabilitiesFromAgents(route);
|
|
570
|
+
if (diffs.length > 0) {
|
|
571
|
+
logger.info(
|
|
572
|
+
{ name, route, diffs },
|
|
573
|
+
'Revoked plugin capabilities from agents on uninstall',
|
|
574
|
+
);
|
|
575
|
+
}
|
|
459
576
|
}
|
|
460
577
|
} catch (err) {
|
|
461
578
|
logger.warn(
|
|
@@ -470,6 +587,83 @@ export function uninstallPlugin(name, logger) {
|
|
|
470
587
|
});
|
|
471
588
|
}
|
|
472
589
|
|
|
590
|
+
// --- In-place update (agent-hosted plugins) ---
|
|
591
|
+
|
|
592
|
+
const updateRowSql = `
|
|
593
|
+
UPDATE plugins SET
|
|
594
|
+
display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
|
|
595
|
+
config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
|
|
596
|
+
agent_half_pin = ?
|
|
597
|
+
WHERE name = ?`;
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Rewrite an installed plugin's row from a manifest (an update, or its
|
|
601
|
+
* reversal) with the agent half pinned for that version (null when Lamaste
|
|
602
|
+
* installs none). Status and install time stay.
|
|
603
|
+
*/
|
|
604
|
+
export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
|
|
605
|
+
const s = await getStmts();
|
|
606
|
+
s.db
|
|
607
|
+
.prepare(updateRowSql)
|
|
608
|
+
.run(
|
|
609
|
+
nullableString(manifest.displayName),
|
|
610
|
+
manifest.version,
|
|
611
|
+
nullableString(manifest.description),
|
|
612
|
+
JSON.stringify(manifest.capabilities ?? []),
|
|
613
|
+
nullableJson(manifest.packages),
|
|
614
|
+
nullableJson(manifest.panel),
|
|
615
|
+
nullableJson(manifest.config),
|
|
616
|
+
nullableJson(manifest.modes),
|
|
617
|
+
manifest.hosting ?? DEFAULT_PLUGIN_HOSTING,
|
|
618
|
+
nullableJson(manifest.agent),
|
|
619
|
+
nullableJson(manifest.hooks),
|
|
620
|
+
nullableString(hooksEntrySha256),
|
|
621
|
+
nullableJson(agentHalfPin),
|
|
622
|
+
name,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Install `<packageName>@<version>` over the installed copy and read it
|
|
628
|
+
* back: the validated manifest and its relay entry module's SHA-256. The
|
|
629
|
+
* row is not touched — the caller compares the result with what was
|
|
630
|
+
* reviewed and then writes it. Refuses a package whose manifest names
|
|
631
|
+
* another plugin.
|
|
632
|
+
*/
|
|
633
|
+
export function replacePluginPackage(name, packageName, version, logger) {
|
|
634
|
+
return withPluginLock(async () => {
|
|
635
|
+
const bare = bareLamalibrePackageName(packageName);
|
|
636
|
+
const spec = `${bare}@${version}`;
|
|
637
|
+
assertSafeLamalibrePackage(spec);
|
|
638
|
+
logger?.info({ name, spec }, 'Installing plugin package version');
|
|
639
|
+
try {
|
|
640
|
+
await execa('npm', ['install', '--ignore-scripts', spec], {
|
|
641
|
+
cwd: STATE_DIR,
|
|
642
|
+
timeout: 120000,
|
|
643
|
+
});
|
|
644
|
+
} catch (err) {
|
|
645
|
+
logger?.error({ spec, stderr: err.stderr, err: err.message }, 'npm install failed');
|
|
646
|
+
throw Object.assign(new Error(`Failed to install "${spec}"`), { statusCode: 502 });
|
|
647
|
+
}
|
|
648
|
+
const require = createRequire(`${STATE_DIR}/`);
|
|
649
|
+
const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
|
|
650
|
+
const manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
|
|
651
|
+
if (manifest.name !== name) {
|
|
652
|
+
throw Object.assign(new Error(`${spec} is plugin "${manifest.name}", not "${name}"`), {
|
|
653
|
+
statusCode: 400,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (manifest.version !== version) {
|
|
657
|
+
throw Object.assign(
|
|
658
|
+
new Error(`${spec} installed version ${manifest.version} according to its manifest`),
|
|
659
|
+
{ statusCode: 400 },
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
|
|
663
|
+
return { manifest, hooksEntrySha256 };
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
473
667
|
/**
|
|
474
668
|
* Enable a plugin.
|
|
475
669
|
*
|
|
@@ -565,6 +759,42 @@ export async function getPluginCapabilities() {
|
|
|
565
759
|
const parsed = JSON.parse(row.capabilities);
|
|
566
760
|
if (Array.isArray(parsed)) caps.push(...parsed);
|
|
567
761
|
}
|
|
762
|
+
// `plugin:<route>:host` is never declared: Lamaste contributes it for
|
|
763
|
+
// every installed agent-hosted plugin with an agent half, enabled or
|
|
764
|
+
// not — hosting is an administrator's binding and outlives a disable.
|
|
765
|
+
if (row.hosting === 'agent' && row.agent != null && row.route != null) {
|
|
766
|
+
caps.push(pluginHostCapability(row.route));
|
|
767
|
+
}
|
|
568
768
|
}
|
|
569
769
|
return [...new Set(caps)];
|
|
570
770
|
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* One installed plugin by name, or null.
|
|
774
|
+
* @param {string} name
|
|
775
|
+
*/
|
|
776
|
+
export async function findPlugin(name) {
|
|
777
|
+
const s = await getStmts();
|
|
778
|
+
const row = s.selectByName.get(name);
|
|
779
|
+
return row ? rowToPlugin(row) : null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The installed plugin that holds route `route`, or null.
|
|
784
|
+
* @param {string} route
|
|
785
|
+
*/
|
|
786
|
+
export async function findPluginByRoute(route) {
|
|
787
|
+
const s = await getStmts();
|
|
788
|
+
const row = s.selectByRoute.get(route);
|
|
789
|
+
return row ? rowToPlugin(row) : null;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** 409 `route_taken`: another installed plugin holds the route. */
|
|
793
|
+
function routeTakenError(name, route, owner) {
|
|
794
|
+
return Object.assign(
|
|
795
|
+
new Error(
|
|
796
|
+
`Plugin "${name}" would use route "${route}", which installed plugin "${owner}" holds — a route (the name without "-server" or "-agent") names one plugin's API, capabilities, hosting and agent half`,
|
|
797
|
+
),
|
|
798
|
+
{ statusCode: 409, code: 'route_taken', route, owner },
|
|
799
|
+
);
|
|
800
|
+
}
|
|
@@ -23,6 +23,16 @@ function pushInstallConfigPath() {
|
|
|
23
23
|
return path.join(STATE_DIR, 'push-install-config.json');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Actions a push-install policy can allow. */
|
|
27
|
+
export const PUSH_INSTALL_ACTIONS = ['install', 'update', 'uninstall', 'check-prerequisites'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long an agent's push-install window may be opened for, in minutes —
|
|
31
|
+
* enforced when one is opened and reported with the config, so clients
|
|
32
|
+
* offer the same bounds.
|
|
33
|
+
*/
|
|
34
|
+
export const PUSH_INSTALL_WINDOW_MINUTES = Object.freeze({ min: 5, max: 480 });
|
|
35
|
+
|
|
26
36
|
const DEFAULT_POLICY = {
|
|
27
37
|
id: 'default',
|
|
28
38
|
name: 'Default',
|
|
@@ -30,9 +40,33 @@ const DEFAULT_POLICY = {
|
|
|
30
40
|
allowedIps: [],
|
|
31
41
|
deniedIps: [],
|
|
32
42
|
allowedPlugins: [],
|
|
33
|
-
allowedActions: ['install', 'update', 'check-prerequisites'],
|
|
43
|
+
allowedActions: ['install', 'update', 'uninstall', 'check-prerequisites'],
|
|
34
44
|
};
|
|
35
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The default policy's actions before `uninstall` joined them (4.3: removing
|
|
48
|
+
* an agent half Lamaste installed is a push to the agent too).
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_ACTIONS_BEFORE_UNINSTALL = ['install', 'update', 'check-prerequisites'];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A stored default policy whose actions were never edited gets `uninstall`
|
|
54
|
+
* with the default it was written from — the administrator chose nothing
|
|
55
|
+
* that excludes it. A policy that was edited, or any other policy, stays
|
|
56
|
+
* as stored.
|
|
57
|
+
*/
|
|
58
|
+
function withCurrentDefaults(policy) {
|
|
59
|
+
if (
|
|
60
|
+
policy?.id === DEFAULT_POLICY.id &&
|
|
61
|
+
Array.isArray(policy.allowedActions) &&
|
|
62
|
+
policy.allowedActions.length === DEFAULT_ACTIONS_BEFORE_UNINSTALL.length &&
|
|
63
|
+
DEFAULT_ACTIONS_BEFORE_UNINSTALL.every((a, i) => policy.allowedActions[i] === a)
|
|
64
|
+
) {
|
|
65
|
+
return { ...policy, allowedActions: [...DEFAULT_POLICY.allowedActions] };
|
|
66
|
+
}
|
|
67
|
+
return policy;
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
const DEFAULT_PUSH_INSTALL_CONFIG = {
|
|
37
71
|
enabled: false,
|
|
38
72
|
policies: [structuredClone(DEFAULT_POLICY)],
|
|
@@ -51,7 +85,7 @@ export async function readPushInstallConfig() {
|
|
|
51
85
|
return {
|
|
52
86
|
enabled: parsed.enabled ?? false,
|
|
53
87
|
policies: Array.isArray(parsed.policies)
|
|
54
|
-
? parsed.policies
|
|
88
|
+
? parsed.policies.map(withCurrentDefaults)
|
|
55
89
|
: [structuredClone(DEFAULT_POLICY)],
|
|
56
90
|
defaultPolicy: parsed.defaultPolicy || 'default',
|
|
57
91
|
};
|
|
@@ -388,3 +422,55 @@ export async function validatePushInstallAccess(label, sourceIp) {
|
|
|
388
422
|
|
|
389
423
|
return { ok: true, agent, config, policy };
|
|
390
424
|
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Whether `policy` allows `action` on `packageName`. An empty list allows
|
|
428
|
+
* everything (as the policy editor documents). Returns the refusal, or null.
|
|
429
|
+
*
|
|
430
|
+
* @param {{ allowedActions?: string[], allowedPlugins?: string[] }} policy
|
|
431
|
+
* @param {string} action
|
|
432
|
+
* @param {string | null} packageName
|
|
433
|
+
* @returns {{ error: string, statusCode: number } | null}
|
|
434
|
+
*/
|
|
435
|
+
export function pushInstallPolicyRefusal(policy, action, packageName) {
|
|
436
|
+
if (
|
|
437
|
+
Array.isArray(policy.allowedActions) &&
|
|
438
|
+
policy.allowedActions.length > 0 &&
|
|
439
|
+
!policy.allowedActions.includes(action)
|
|
440
|
+
) {
|
|
441
|
+
return { error: `Action "${action}" is not allowed by the assigned policy`, statusCode: 403 };
|
|
442
|
+
}
|
|
443
|
+
if (
|
|
444
|
+
packageName &&
|
|
445
|
+
Array.isArray(policy.allowedPlugins) &&
|
|
446
|
+
policy.allowedPlugins.length > 0 &&
|
|
447
|
+
!policy.allowedPlugins.includes(packageName)
|
|
448
|
+
) {
|
|
449
|
+
return {
|
|
450
|
+
error: `Package "${packageName}" is not allowed by the assigned policy`,
|
|
451
|
+
statusCode: 403,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The push-install gates for one push to agent `label` from `sourceIp`:
|
|
459
|
+
* {@link validatePushInstallAccess} (global switch, the agent's window,
|
|
460
|
+
* the source IP) and the assigned policy's actions and packages. Hosting,
|
|
461
|
+
* updating and removing an agent half Lamaste installs are pushes: the
|
|
462
|
+
* relay has the agent install, switch or remove code as root.
|
|
463
|
+
*
|
|
464
|
+
* @param {string} label
|
|
465
|
+
* @param {string} sourceIp
|
|
466
|
+
* @param {'install' | 'update' | 'uninstall'} action
|
|
467
|
+
* @param {string} packageName the agent half's package
|
|
468
|
+
* @returns {Promise<{ ok: true, policy: object } | { ok: false, error: string, statusCode: number }>}
|
|
469
|
+
*/
|
|
470
|
+
export async function checkPushInstallAction(label, sourceIp, action, packageName) {
|
|
471
|
+
const access = await validatePushInstallAccess(label, sourceIp);
|
|
472
|
+
if (!access.ok) return access;
|
|
473
|
+
const refusal = pushInstallPolicyRefusal(access.policy, action, packageName);
|
|
474
|
+
if (refusal) return { ok: false, ...refusal };
|
|
475
|
+
return { ok: true, policy: access.policy };
|
|
476
|
+
}
|