@lamalibre/create-lamaste 4.1.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 +99 -35
- 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 +32 -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 +1067 -161
- 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 +36 -6
- 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/0005_plugin_hosting.sql +3 -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-oidc.js +33 -16
- 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 +306 -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/storage.js +10 -2
- 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-B0nWuUyL.js +0 -257
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
|
@@ -11,21 +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 {
|
|
16
|
+
DEFAULT_PLUGIN_HOSTING,
|
|
17
|
+
ManifestValidationError,
|
|
17
18
|
RESERVED_API_PREFIXES,
|
|
18
19
|
RESERVED_NAV_LABELS,
|
|
19
20
|
derivePluginRoute,
|
|
21
|
+
hasAgentHalf,
|
|
22
|
+
pluginHostCapability,
|
|
20
23
|
validateManifest as coreValidateManifest,
|
|
21
24
|
} from '@lamalibre/lamaste';
|
|
22
|
-
import { revokePluginCapabilitiesFromAgents } from '@lamalibre/lamaste/server';
|
|
23
25
|
import { listRegisteredSubScopeNames } from './tickets.js';
|
|
24
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';
|
|
25
33
|
|
|
26
34
|
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
27
35
|
const PLUGINS_DIR = path.join(STATE_DIR, 'plugins');
|
|
28
|
-
const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
|
|
29
36
|
|
|
30
37
|
// Promise-chain mutex. SQLite's BEGIN IMMEDIATE serialises a single write
|
|
31
38
|
// transaction, but install/uninstall are read-modify-write sequences that
|
|
@@ -56,12 +63,14 @@ async function getStmts() {
|
|
|
56
63
|
|
|
57
64
|
selectAll: db.prepare('SELECT * FROM plugins'),
|
|
58
65
|
selectByName: db.prepare('SELECT * FROM plugins WHERE name = ?'),
|
|
66
|
+
selectByRoute: db.prepare('SELECT * FROM plugins WHERE route = ?'),
|
|
59
67
|
selectByPackageName: db.prepare('SELECT * FROM plugins WHERE package_name = ?'),
|
|
60
68
|
insert: db.prepare(`
|
|
61
69
|
INSERT INTO plugins
|
|
62
|
-
(name, display_name, package_name, version, description, capabilities,
|
|
63
|
-
packages, panel, config, modes,
|
|
64
|
-
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
65
74
|
`),
|
|
66
75
|
deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
|
|
67
76
|
deleteAll: db.prepare('DELETE FROM plugins'),
|
|
@@ -86,14 +95,23 @@ async function getStmts() {
|
|
|
86
95
|
// JSON-encoded TEXT in storage. They round-trip through JSON.parse/stringify
|
|
87
96
|
// so callers see the same shape the JSON-backed module returned — including
|
|
88
97
|
// `undefined` for optional fields (mirroring the JSON object layout where
|
|
89
|
-
// missing keys are absent rather than null).
|
|
98
|
+
// missing keys are absent rather than null). `hosting` is plain TEXT
|
|
99
|
+
// ('server' | 'agent'; rows from before 4.2 default to 'server'): an
|
|
100
|
+
// agent-hosted plugin is installed (npm, manifest) like any other, but the
|
|
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.
|
|
90
106
|
|
|
91
107
|
function rowToPlugin(row) {
|
|
92
108
|
const entry = {
|
|
93
109
|
name: row.name,
|
|
110
|
+
route: row.route ?? null,
|
|
94
111
|
packageName: row.package_name,
|
|
95
112
|
version: row.version,
|
|
96
113
|
capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
|
|
114
|
+
hosting: row.hosting,
|
|
97
115
|
status: row.status,
|
|
98
116
|
installedAt: row.installed_at,
|
|
99
117
|
};
|
|
@@ -103,6 +121,10 @@ function rowToPlugin(row) {
|
|
|
103
121
|
if (row.panel != null) entry.panel = JSON.parse(row.panel);
|
|
104
122
|
if (row.config != null) entry.config = JSON.parse(row.config);
|
|
105
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);
|
|
106
128
|
if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
|
|
107
129
|
return entry;
|
|
108
130
|
}
|
|
@@ -146,6 +168,7 @@ export async function writePlugins(data) {
|
|
|
146
168
|
for (const p of list) {
|
|
147
169
|
s.insert.run(
|
|
148
170
|
p.name,
|
|
171
|
+
p.route === undefined ? derivePluginRoute(p.name) : p.route,
|
|
149
172
|
nullableString(p.displayName),
|
|
150
173
|
p.packageName,
|
|
151
174
|
p.version,
|
|
@@ -155,6 +178,11 @@ export async function writePlugins(data) {
|
|
|
155
178
|
nullableJson(p.panel),
|
|
156
179
|
nullableJson(p.config),
|
|
157
180
|
nullableJson(p.modes),
|
|
181
|
+
p.hosting ?? DEFAULT_PLUGIN_HOSTING,
|
|
182
|
+
nullableJson(p.agent),
|
|
183
|
+
nullableJson(p.hooks),
|
|
184
|
+
nullableString(p.hooksEntrySha256),
|
|
185
|
+
nullableJson(p.agentHalfPin),
|
|
158
186
|
p.status,
|
|
159
187
|
p.installedAt,
|
|
160
188
|
nullableString(p.enabledAt),
|
|
@@ -173,7 +201,7 @@ export async function writePlugins(data) {
|
|
|
173
201
|
*
|
|
174
202
|
* @param {object} raw - The raw manifest object
|
|
175
203
|
* @returns {object} The validated and parsed manifest
|
|
176
|
-
* @throws {
|
|
204
|
+
* @throws {ManifestValidationError} If validation fails
|
|
177
205
|
*/
|
|
178
206
|
export function validateManifest(raw) {
|
|
179
207
|
return coreValidateManifest(raw);
|
|
@@ -230,10 +258,74 @@ async function syncOidcClientsBestEffort(logger) {
|
|
|
230
258
|
}
|
|
231
259
|
}
|
|
232
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
|
+
|
|
233
318
|
export function installPlugin(packageName, logger) {
|
|
234
319
|
return withPluginLock(async () => {
|
|
235
320
|
// Validate package scope
|
|
236
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(() => {});
|
|
237
329
|
|
|
238
330
|
const s = await getStmts();
|
|
239
331
|
|
|
@@ -256,15 +348,18 @@ export function installPlugin(packageName, logger) {
|
|
|
256
348
|
}
|
|
257
349
|
|
|
258
350
|
// Install the npm package
|
|
259
|
-
logger.info({ packageName }, 'Installing plugin package');
|
|
351
|
+
logger.info({ packageName: installSpec }, 'Installing plugin package');
|
|
260
352
|
try {
|
|
261
|
-
await execa('npm', ['install', '--ignore-scripts',
|
|
353
|
+
await execa('npm', ['install', '--ignore-scripts', installSpec], {
|
|
262
354
|
cwd: STATE_DIR,
|
|
263
355
|
timeout: 120000,
|
|
264
356
|
});
|
|
265
357
|
} catch (err) {
|
|
266
|
-
logger.error(
|
|
267
|
-
|
|
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 });
|
|
268
363
|
}
|
|
269
364
|
|
|
270
365
|
// Read the plugin manifest from the installed package
|
|
@@ -277,70 +372,58 @@ export function installPlugin(packageName, logger) {
|
|
|
277
372
|
} catch (err) {
|
|
278
373
|
// Clean up the installed package if manifest is missing/invalid
|
|
279
374
|
logger.warn({ packageName, err: err.message }, 'Invalid or missing manifest, uninstalling');
|
|
280
|
-
await
|
|
375
|
+
await discardPackage();
|
|
281
376
|
|
|
282
|
-
if (err instanceof
|
|
283
|
-
throw Object.assign(
|
|
284
|
-
new Error(`Invalid plugin manifest: ${err.errors.map((e) => e.message).join(', ')}`),
|
|
285
|
-
{ statusCode: 400 },
|
|
286
|
-
);
|
|
377
|
+
if (err instanceof ManifestValidationError) {
|
|
378
|
+
throw Object.assign(new Error(err.message), { statusCode: 400 });
|
|
287
379
|
}
|
|
288
380
|
throw Object.assign(new Error(`Plugin manifest not found in "${packageName}"`), {
|
|
289
381
|
statusCode: 400,
|
|
290
382
|
});
|
|
291
383
|
}
|
|
292
384
|
|
|
293
|
-
|
|
294
|
-
if (
|
|
295
|
-
await
|
|
296
|
-
throw Object.assign(new Error(
|
|
297
|
-
statusCode: 400,
|
|
298
|
-
});
|
|
385
|
+
const policyError = await manifestPolicyError(manifest);
|
|
386
|
+
if (policyError) {
|
|
387
|
+
await discardPackage();
|
|
388
|
+
throw Object.assign(new Error(policyError.message), { statusCode: policyError.statusCode });
|
|
299
389
|
}
|
|
300
390
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
311
|
-
}
|
|
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 });
|
|
312
400
|
}
|
|
313
401
|
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
);
|
|
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 });
|
|
323
411
|
}
|
|
324
412
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
'Pick a different action name or unregister the conflicting ticket scope first.',
|
|
340
|
-
),
|
|
341
|
-
{ statusCode: 409 },
|
|
342
|
-
);
|
|
343
|
-
}
|
|
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);
|
|
344
427
|
}
|
|
345
428
|
|
|
346
429
|
// Create the plugin state directory
|
|
@@ -350,6 +433,7 @@ export function installPlugin(packageName, logger) {
|
|
|
350
433
|
// Build the entry, then persist via INSERT
|
|
351
434
|
const entry = {
|
|
352
435
|
name: manifest.name,
|
|
436
|
+
route,
|
|
353
437
|
displayName: manifest.displayName,
|
|
354
438
|
packageName,
|
|
355
439
|
version: manifest.version,
|
|
@@ -359,6 +443,11 @@ export function installPlugin(packageName, logger) {
|
|
|
359
443
|
panel: manifest.panel,
|
|
360
444
|
config: manifest.config,
|
|
361
445
|
modes: manifest.modes,
|
|
446
|
+
hosting: manifest.hosting,
|
|
447
|
+
agent: manifest.agent,
|
|
448
|
+
hooks: manifest.hooks,
|
|
449
|
+
hooksEntrySha256,
|
|
450
|
+
agentHalfPin,
|
|
362
451
|
status: 'disabled',
|
|
363
452
|
installedAt: new Date().toISOString(),
|
|
364
453
|
};
|
|
@@ -367,6 +456,7 @@ export function installPlugin(packageName, logger) {
|
|
|
367
456
|
try {
|
|
368
457
|
s.insert.run(
|
|
369
458
|
entry.name,
|
|
459
|
+
entry.route,
|
|
370
460
|
nullableString(entry.displayName),
|
|
371
461
|
entry.packageName,
|
|
372
462
|
entry.version,
|
|
@@ -376,6 +466,11 @@ export function installPlugin(packageName, logger) {
|
|
|
376
466
|
nullableJson(entry.panel),
|
|
377
467
|
nullableJson(entry.config),
|
|
378
468
|
nullableJson(entry.modes),
|
|
469
|
+
entry.hosting,
|
|
470
|
+
nullableJson(entry.agent),
|
|
471
|
+
nullableJson(entry.hooks),
|
|
472
|
+
nullableString(entry.hooksEntrySha256),
|
|
473
|
+
nullableJson(entry.agentHalfPin),
|
|
379
474
|
entry.status,
|
|
380
475
|
entry.installedAt,
|
|
381
476
|
null,
|
|
@@ -416,6 +511,24 @@ export function uninstallPlugin(name, logger) {
|
|
|
416
511
|
});
|
|
417
512
|
}
|
|
418
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
|
+
|
|
419
532
|
// Uninstall the npm package
|
|
420
533
|
logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
|
|
421
534
|
await execa('npm', ['uninstall', plugin.packageName], {
|
|
@@ -442,12 +555,24 @@ export function uninstallPlugin(name, logger) {
|
|
|
442
555
|
|
|
443
556
|
// Sweep this plugin's capabilities off every agent so re-installing the
|
|
444
557
|
// plugin later does NOT silently re-grant. Failure here is non-fatal
|
|
445
|
-
// (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.
|
|
446
561
|
try {
|
|
447
|
-
const route =
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|
|
451
576
|
}
|
|
452
577
|
} catch (err) {
|
|
453
578
|
logger.warn(
|
|
@@ -462,6 +587,83 @@ export function uninstallPlugin(name, logger) {
|
|
|
462
587
|
});
|
|
463
588
|
}
|
|
464
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
|
+
|
|
465
667
|
/**
|
|
466
668
|
* Enable a plugin.
|
|
467
669
|
*
|
|
@@ -557,6 +759,42 @@ export async function getPluginCapabilities() {
|
|
|
557
759
|
const parsed = JSON.parse(row.capabilities);
|
|
558
760
|
if (Array.isArray(parsed)) caps.push(...parsed);
|
|
559
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
|
+
}
|
|
560
768
|
}
|
|
561
769
|
return [...new Set(caps)];
|
|
562
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
|
+
}
|