@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-running plugin operations — hosting, unhosting, update, uninstall of
|
|
3
|
+
* a hosted plugin. Their pipelines wait on agents (a sync every 30 s) and on
|
|
4
|
+
* plugin hooks, so they run in the background: the API answers 202 with the
|
|
5
|
+
* operation, and the panel and CLI follow its steps.
|
|
6
|
+
*
|
|
7
|
+
* One operation runs per plugin at a time; a second is refused (409) —
|
|
8
|
+
* hosting and uninstalling the same plugin can no longer interleave. Steps
|
|
9
|
+
* and outcome are kept in state.db (metadata only). An operation still
|
|
10
|
+
* `running` when the panel starts was interrupted by a restart and is
|
|
11
|
+
* marked so.
|
|
12
|
+
*
|
|
13
|
+
* What an operation creates that is shown exactly once — the secret of a
|
|
14
|
+
* confidential OIDC client an update added — is never written to state.db:
|
|
15
|
+
* it is held in memory for {@link REVEAL_TTL_MS} and handed out by the first
|
|
16
|
+
* read of the finished operation ({@link takeOperationReveal}).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { derivePluginRoute } from '@lamalibre/lamaste';
|
|
21
|
+
import { cancelAgentJobs } from './agent-jobs.js';
|
|
22
|
+
import { httpError } from './app-error.js';
|
|
23
|
+
import { getStateDb } from './state-db.js';
|
|
24
|
+
|
|
25
|
+
/** Kept per plugin; older finished operations are pruned. */
|
|
26
|
+
const OPERATIONS_KEPT_PER_PLUGIN = 20;
|
|
27
|
+
|
|
28
|
+
/** How long a once-shown secret waits to be read before it is dropped. */
|
|
29
|
+
export const REVEAL_TTL_MS = 60 * 60_000;
|
|
30
|
+
|
|
31
|
+
/** plugin → { id, kind, done } */
|
|
32
|
+
const running = new Map();
|
|
33
|
+
|
|
34
|
+
/** operation id → { oidcClients: [{ id, secret }], expiresAt } — memory only. */
|
|
35
|
+
const reveals = new Map();
|
|
36
|
+
|
|
37
|
+
function pruneReveals(now = Date.now()) {
|
|
38
|
+
for (const [id, entry] of reveals) if (entry.expiresAt <= now) reveals.delete(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What finished operation `id` has to show once — `{ oidcClients }`, the
|
|
43
|
+
* secrets of the confidential OIDC clients it created — and forget it; null
|
|
44
|
+
* when there is nothing (or it was shown already, or waited too long).
|
|
45
|
+
*
|
|
46
|
+
* @param {string} id
|
|
47
|
+
* @returns {{ oidcClients: Array<{ id: string, secret: string }> } | null}
|
|
48
|
+
*/
|
|
49
|
+
export function takeOperationReveal(id) {
|
|
50
|
+
pruneReveals();
|
|
51
|
+
const entry = reveals.get(id);
|
|
52
|
+
if (!entry) return null;
|
|
53
|
+
reveals.delete(id);
|
|
54
|
+
return { oidcClients: entry.oidcClients };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether finished operation `id` still has something to show once — what
|
|
59
|
+
* {@link takeOperationReveal} would hand out — without handing it out.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} id
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function hasOperationReveal(id) {
|
|
65
|
+
pruneReveals();
|
|
66
|
+
return reveals.has(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rowToOperation(row) {
|
|
70
|
+
if (!row) return null;
|
|
71
|
+
const params = JSON.parse(row.params || '{}');
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
plugin: row.plugin,
|
|
75
|
+
kind: row.kind,
|
|
76
|
+
actor: row.actor,
|
|
77
|
+
status: row.status,
|
|
78
|
+
steps: JSON.parse(row.steps || '[]'),
|
|
79
|
+
error: row.error,
|
|
80
|
+
warnings: params.warnings ?? [],
|
|
81
|
+
params: params.public ?? {},
|
|
82
|
+
startedAt: row.started_at,
|
|
83
|
+
finishedAt: row.finished_at,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A step failed in a way the pipeline handles (not a bug). */
|
|
88
|
+
export class StageFailure extends Error {
|
|
89
|
+
constructor(message) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = 'StageFailure';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a pipeline uses to record its progress. Steps are written to the
|
|
97
|
+
* database as they start and end, so a reader sees a running operation move.
|
|
98
|
+
*/
|
|
99
|
+
export class OperationContext {
|
|
100
|
+
#id;
|
|
101
|
+
#db;
|
|
102
|
+
#publicParams;
|
|
103
|
+
#actor;
|
|
104
|
+
steps = [];
|
|
105
|
+
warnings = [];
|
|
106
|
+
|
|
107
|
+
constructor(id, db, publicParams, actor) {
|
|
108
|
+
this.#id = id;
|
|
109
|
+
this.#db = db;
|
|
110
|
+
this.#publicParams = publicParams;
|
|
111
|
+
this.#actor = actor;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get id() {
|
|
115
|
+
return this.#id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Who started the operation (`admin`, `auto-approve`, …), for audit entries. */
|
|
119
|
+
get actor() {
|
|
120
|
+
return this.#actor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#persist() {
|
|
124
|
+
this.#db
|
|
125
|
+
.prepare('UPDATE plugin_operations SET steps = ?, params = ? WHERE id = ?')
|
|
126
|
+
.run(
|
|
127
|
+
JSON.stringify(this.steps),
|
|
128
|
+
JSON.stringify({ public: this.#publicParams, warnings: this.warnings }),
|
|
129
|
+
this.#id,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Run `fn` as a named step; its failure is recorded and rethrown. */
|
|
134
|
+
async step(name, fn) {
|
|
135
|
+
const step = { name, status: 'running', startedAt: new Date().toISOString() };
|
|
136
|
+
this.steps.push(step);
|
|
137
|
+
this.#persist();
|
|
138
|
+
try {
|
|
139
|
+
const result = await fn();
|
|
140
|
+
step.status = 'done';
|
|
141
|
+
step.finishedAt = new Date().toISOString();
|
|
142
|
+
this.#persist();
|
|
143
|
+
return result;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
step.status = 'failed';
|
|
146
|
+
step.finishedAt = new Date().toISOString();
|
|
147
|
+
step.error = String(err?.message ?? err).slice(0, 500);
|
|
148
|
+
this.#persist();
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Record a step that did not run, and why. */
|
|
154
|
+
skip(name, reason) {
|
|
155
|
+
const at = new Date().toISOString();
|
|
156
|
+
this.steps.push({ name, status: 'skipped', startedAt: at, finishedAt: at, detail: reason });
|
|
157
|
+
this.#persist();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Something went wrong that does not fail the operation (an uninstall stage, a rollback step). */
|
|
161
|
+
warn(message) {
|
|
162
|
+
this.warnings.push(String(message).slice(0, 500));
|
|
163
|
+
this.#persist();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Secrets of confidential OIDC clients this operation created, for the
|
|
168
|
+
* admin to see once ({@link takeOperationReveal}) — never stored.
|
|
169
|
+
*
|
|
170
|
+
* @param {Array<{ id: string, secret: string }>} oidcClients
|
|
171
|
+
*/
|
|
172
|
+
revealOidcClientSecrets(oidcClients) {
|
|
173
|
+
if (oidcClients.length === 0) return;
|
|
174
|
+
pruneReveals();
|
|
175
|
+
reveals.set(this.#id, {
|
|
176
|
+
oidcClients: oidcClients.map(({ id, secret }) => ({ id, secret })),
|
|
177
|
+
expiresAt: Date.now() + REVEAL_TTL_MS,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start an operation for `plugin` and return it at once; `run(op)` continues
|
|
184
|
+
* in the background. `run` resolves with the final status (`succeeded` by
|
|
185
|
+
* default, or `rolled-back`) or throws (`failed`, with the message).
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<{ operation: object, done: Promise<object> }>}
|
|
188
|
+
*/
|
|
189
|
+
export async function startPluginOperation({ plugin, kind, actor, params = {}, logger, run }) {
|
|
190
|
+
const current = running.get(plugin);
|
|
191
|
+
if (current) {
|
|
192
|
+
throw httpError(
|
|
193
|
+
`Plugin "${plugin}" has an operation in progress (${current.kind}) — wait for it to finish`,
|
|
194
|
+
409,
|
|
195
|
+
{ operationId: current.id },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const db = await getStateDb();
|
|
199
|
+
const id = crypto.randomUUID();
|
|
200
|
+
const startedAt = new Date().toISOString();
|
|
201
|
+
// Reserve before the first await below, so two requests cannot both start.
|
|
202
|
+
const entry = { id, kind, done: null };
|
|
203
|
+
running.set(plugin, entry);
|
|
204
|
+
try {
|
|
205
|
+
db.prepare(
|
|
206
|
+
`INSERT INTO plugin_operations (id, plugin, kind, actor, status, steps, params, started_at)
|
|
207
|
+
VALUES (?, ?, ?, ?, 'running', '[]', ?, ?)`,
|
|
208
|
+
).run(id, plugin, kind, actor, JSON.stringify({ public: params, warnings: [] }), startedAt);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
running.delete(plugin);
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
const op = new OperationContext(id, db, params, actor);
|
|
214
|
+
|
|
215
|
+
const finish = (status, error) => {
|
|
216
|
+
db.prepare(
|
|
217
|
+
'UPDATE plugin_operations SET status = ?, error = ?, finished_at = ? WHERE id = ?',
|
|
218
|
+
).run(status, error ?? null, new Date().toISOString(), id);
|
|
219
|
+
db.prepare(
|
|
220
|
+
`DELETE FROM plugin_operations WHERE plugin = ? AND status != 'running' AND id NOT IN
|
|
221
|
+
(SELECT id FROM plugin_operations WHERE plugin = ? ORDER BY started_at DESC LIMIT ?)`,
|
|
222
|
+
).run(plugin, plugin, OPERATIONS_KEPT_PER_PLUGIN);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
entry.done = (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const result = await run(op);
|
|
228
|
+
finish(result?.status ?? 'succeeded', result?.error ?? null);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (!(err instanceof StageFailure)) {
|
|
231
|
+
logger?.error({ plugin, kind, err: err?.message }, 'Plugin operation failed');
|
|
232
|
+
}
|
|
233
|
+
finish('failed', String(err?.message ?? err).slice(0, 500));
|
|
234
|
+
} finally {
|
|
235
|
+
running.delete(plugin);
|
|
236
|
+
}
|
|
237
|
+
return getPluginOperation(plugin, id);
|
|
238
|
+
})();
|
|
239
|
+
|
|
240
|
+
return { operation: await getPluginOperation(plugin, id), done: entry.done };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function isPluginOperationRunning(plugin) {
|
|
244
|
+
return running.has(plugin);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One operation, or the latest of the plugin when `id` is omitted. */
|
|
248
|
+
export async function getPluginOperation(plugin, id) {
|
|
249
|
+
const db = await getStateDb();
|
|
250
|
+
const row = id
|
|
251
|
+
? db.prepare('SELECT * FROM plugin_operations WHERE plugin = ? AND id = ?').get(plugin, id)
|
|
252
|
+
: db
|
|
253
|
+
.prepare(
|
|
254
|
+
'SELECT * FROM plugin_operations WHERE plugin = ? ORDER BY started_at DESC LIMIT 1',
|
|
255
|
+
)
|
|
256
|
+
.get(plugin);
|
|
257
|
+
return rowToOperation(row);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function listPluginOperations(plugin, limit = 10) {
|
|
261
|
+
const db = await getStateDb();
|
|
262
|
+
return db
|
|
263
|
+
.prepare('SELECT * FROM plugin_operations WHERE plugin = ? ORDER BY started_at DESC LIMIT ?')
|
|
264
|
+
.all(plugin, Math.min(Math.max(1, limit), OPERATIONS_KEPT_PER_PLUGIN))
|
|
265
|
+
.map(rowToOperation);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* At start-up: operations a restart cut short. Their plugins' pending agent
|
|
270
|
+
* jobs are cancelled — nothing waits for them any more, and an agent must
|
|
271
|
+
* not install or remove an agent half for a pipeline that is gone (removing
|
|
272
|
+
* the hosting later removes whatever such a job may have left).
|
|
273
|
+
*/
|
|
274
|
+
export async function markInterruptedOperations(logger) {
|
|
275
|
+
const db = await getStateDb();
|
|
276
|
+
const rows = db
|
|
277
|
+
.prepare("SELECT id, plugin, kind FROM plugin_operations WHERE status = 'running'")
|
|
278
|
+
.all();
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
db.prepare(
|
|
281
|
+
"UPDATE plugin_operations SET status = 'interrupted', error = ?, finished_at = ? WHERE id = ?",
|
|
282
|
+
).run('The panel restarted while the operation ran', new Date().toISOString(), row.id);
|
|
283
|
+
await cancelAgentJobs(derivePluginRoute(row.plugin));
|
|
284
|
+
logger?.warn(
|
|
285
|
+
{ plugin: row.plugin, kind: row.kind, id: row.id },
|
|
286
|
+
'Plugin operation interrupted by a restart',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Wait for a running operation (tests and the CLI's in-process paths). */
|
|
292
|
+
export async function waitForPluginOperation(plugin) {
|
|
293
|
+
const entry = running.get(plugin);
|
|
294
|
+
return entry ? entry.done : getPluginOperation(plugin);
|
|
295
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading plugin packages: the installed copy under `<STATE_DIR>/node_modules`
|
|
3
|
+
* and — for an update — a version not installed yet, fetched with `npm pack`
|
|
4
|
+
* and read in memory (never extracted), so its manifest and relay hook
|
|
5
|
+
* module can be shown and approved before anything of it is installed.
|
|
6
|
+
*
|
|
7
|
+
* The relay hook module is read with care: its path comes from the
|
|
8
|
+
* manifest, so it must stay inside the package, be a regular file (not a
|
|
9
|
+
* link) and be no larger than {@link HOOK_MODULE_MAX_BYTES}.
|
|
10
|
+
*
|
|
11
|
+
* An agent half Lamaste installs onto an agent is never fetched here: its
|
|
12
|
+
* tarball's integrity is read from the registry ({@link pinAgentHalf}) and
|
|
13
|
+
* pinned with the version, and the agent fetches the tarball itself and
|
|
14
|
+
* installs it only when it matches.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { lstat, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execa } from 'execa';
|
|
22
|
+
import {
|
|
23
|
+
AGENT_HALF_INTEGRITY_RE,
|
|
24
|
+
AgentHalfVersionSchema,
|
|
25
|
+
agentHalfOf,
|
|
26
|
+
hookEntrySha256,
|
|
27
|
+
relayHookSetNeedsEntry,
|
|
28
|
+
relayHookSetOf,
|
|
29
|
+
} from '@lamalibre/lamaste';
|
|
30
|
+
import { readTarballFiles } from '@lamalibre/lamaste/server';
|
|
31
|
+
import { httpError } from './app-error.js';
|
|
32
|
+
|
|
33
|
+
const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
|
|
34
|
+
|
|
35
|
+
/** Mirrors lamaste-priv's MAX_HOOK_MODULE_BYTES. */
|
|
36
|
+
export const HOOK_MODULE_MAX_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
const PACKAGE_RE = /^@lamalibre\/[a-z0-9][a-z0-9._-]{0,213}$/;
|
|
39
|
+
const VERSION_RE = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
40
|
+
|
|
41
|
+
/** `@lamalibre/x@1.2.3` → `@lamalibre/x`. */
|
|
42
|
+
export function bareLamalibrePackageName(spec) {
|
|
43
|
+
const at = spec.indexOf('@', 1);
|
|
44
|
+
return at === -1 ? spec : spec.slice(0, at);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The installed package's directory (real path). */
|
|
48
|
+
export async function installedPackageDir(packageName) {
|
|
49
|
+
const bare = bareLamalibrePackageName(packageName);
|
|
50
|
+
if (!PACKAGE_RE.test(bare)) throw httpError('Invalid package name', 400);
|
|
51
|
+
const require = createRequire(`${STATE_DIR}/`);
|
|
52
|
+
const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
|
|
53
|
+
return realpath(path.dirname(manifestPath));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The relay entry module's bytes, from an installed package directory.
|
|
58
|
+
* Refuses a path that leaves the package, a link, or an oversized file.
|
|
59
|
+
*/
|
|
60
|
+
export async function readInstalledHookModule(packageDir, entry) {
|
|
61
|
+
const resolved = path.resolve(packageDir, entry);
|
|
62
|
+
if (!resolved.startsWith(`${packageDir}${path.sep}`)) {
|
|
63
|
+
throw httpError('The relay hook module lies outside its package', 400);
|
|
64
|
+
}
|
|
65
|
+
const st = await lstat(resolved).catch(() => null);
|
|
66
|
+
if (!st || !st.isFile()) {
|
|
67
|
+
throw httpError(`The relay hook module "${entry}" is not a file in the package`, 400);
|
|
68
|
+
}
|
|
69
|
+
if (st.size > HOOK_MODULE_MAX_BYTES) {
|
|
70
|
+
throw httpError(`The relay hook module is larger than ${HOOK_MODULE_MAX_BYTES} bytes`, 400);
|
|
71
|
+
}
|
|
72
|
+
// The real path, again: a directory link on the way would have been followed.
|
|
73
|
+
if ((await realpath(resolved)) !== resolved) {
|
|
74
|
+
throw httpError('The relay hook module path goes through a link', 400);
|
|
75
|
+
}
|
|
76
|
+
return readFile(resolved);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* For a validated manifest of an installed package: the relay entry
|
|
81
|
+
* module's SHA-256, or null when the plugin has no relay hooks of its own.
|
|
82
|
+
*/
|
|
83
|
+
export async function installedHookEntrySha256(packageName, manifest) {
|
|
84
|
+
const set = relayHookSetOf(manifest);
|
|
85
|
+
if (!set || !relayHookSetNeedsEntry(set) || set.entry === null) return null;
|
|
86
|
+
const dir = await installedPackageDir(packageName);
|
|
87
|
+
return hookEntrySha256(await readInstalledHookModule(dir, set.entry));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch `<package>@<version>` from the registry STATE_DIR is configured
|
|
92
|
+
* for (`npm pack`, as the panel's user) and read its manifest and, when
|
|
93
|
+
* the manifest names one, its relay hook module — in memory only.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} packageName bare `@lamalibre/<name>`
|
|
96
|
+
* @param {string} version an exact version or a dist-tag (`latest`)
|
|
97
|
+
* @returns {Promise<{ version: string, manifestRaw: unknown, readHookModule: (entry: string) => Buffer | null }>}
|
|
98
|
+
*/
|
|
99
|
+
export async function readRegistryPackage(packageName, version) {
|
|
100
|
+
if (!PACKAGE_RE.test(packageName)) throw httpError('Invalid package name', 400);
|
|
101
|
+
if (!VERSION_RE.test(version)) throw httpError('Invalid version', 400);
|
|
102
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'lamaste-plugin-stage-'));
|
|
103
|
+
try {
|
|
104
|
+
let packed;
|
|
105
|
+
try {
|
|
106
|
+
const { stdout } = await execa(
|
|
107
|
+
'npm',
|
|
108
|
+
[
|
|
109
|
+
'pack',
|
|
110
|
+
`${packageName}@${version}`,
|
|
111
|
+
'--json',
|
|
112
|
+
'--ignore-scripts',
|
|
113
|
+
'--pack-destination',
|
|
114
|
+
dir,
|
|
115
|
+
],
|
|
116
|
+
{ cwd: STATE_DIR, timeout: 120000 },
|
|
117
|
+
);
|
|
118
|
+
packed = JSON.parse(stdout);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw httpError(
|
|
121
|
+
`Could not fetch ${packageName}@${version} from the registry: ${(err.stderr || err.message || '').split('\n')[0]}`,
|
|
122
|
+
502,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const info = Array.isArray(packed) ? packed[0] : null;
|
|
126
|
+
if (!info || typeof info.filename !== 'string' || typeof info.version !== 'string') {
|
|
127
|
+
throw httpError('npm pack returned no tarball', 502);
|
|
128
|
+
}
|
|
129
|
+
const tgz = await readFile(path.join(dir, path.basename(info.filename)));
|
|
130
|
+
const manifestFile = 'package/lamaste-plugin.json';
|
|
131
|
+
const first = readTarballFiles(tgz, new Set([manifestFile]));
|
|
132
|
+
const manifestBytes = first.get(manifestFile);
|
|
133
|
+
if (!manifestBytes)
|
|
134
|
+
throw httpError(`${packageName}@${info.version} has no lamaste-plugin.json`, 400);
|
|
135
|
+
let manifestRaw;
|
|
136
|
+
try {
|
|
137
|
+
manifestRaw = JSON.parse(manifestBytes.toString('utf-8'));
|
|
138
|
+
} catch {
|
|
139
|
+
throw httpError(`${packageName}@${info.version} has an unreadable lamaste-plugin.json`, 400);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
version: info.version,
|
|
143
|
+
manifestRaw,
|
|
144
|
+
readHookModule(entry) {
|
|
145
|
+
const normalized = path.posix.normalize(entry);
|
|
146
|
+
if (normalized.startsWith('../') || path.posix.isAbsolute(normalized)) return null;
|
|
147
|
+
const name = `package/${normalized}`;
|
|
148
|
+
return (
|
|
149
|
+
readTarballFiles(tgz, new Set([name]), {
|
|
150
|
+
maxArchiveBytes: 64 * 1024 * 1024,
|
|
151
|
+
maxFileBytes: HOOK_MODULE_MAX_BYTES,
|
|
152
|
+
}).get(name) ?? null
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
} finally {
|
|
157
|
+
await rm(dir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The first line of what a failed npm call said, for an error message (npm prints no credential). */
|
|
162
|
+
function npmFailureLine(err) {
|
|
163
|
+
try {
|
|
164
|
+
const summary = JSON.parse(err.stdout ?? '')?.error?.summary;
|
|
165
|
+
if (typeof summary === 'string' && summary.length > 0) return summary.split('\n')[0];
|
|
166
|
+
} catch {
|
|
167
|
+
// Not JSON: fall back to standard error.
|
|
168
|
+
}
|
|
169
|
+
return (err.stderr || err.message || '').split('\n')[0];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The npm Subresource Integrity of `<package>@<version>`'s tarball, as the
|
|
174
|
+
* registry STATE_DIR is configured for publishes it — `npm view … dist.integrity`
|
|
175
|
+
* as the panel's user, so the registry and credential are the ones plugin
|
|
176
|
+
* installs use. Only a sha512 SRI is accepted: it is what the approval
|
|
177
|
+
* binds and what the agent checks the tarball it fetches against.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} packageName bare `@lamalibre/<name>`
|
|
180
|
+
* @param {string} version an exact version
|
|
181
|
+
* @returns {Promise<string>} `sha512-<base64>`
|
|
182
|
+
*/
|
|
183
|
+
export async function readRegistryIntegrity(packageName, version) {
|
|
184
|
+
if (!PACKAGE_RE.test(packageName)) throw httpError('Invalid package name', 400);
|
|
185
|
+
if (!AgentHalfVersionSchema.safeParse(version).success) throw httpError('Invalid version', 400);
|
|
186
|
+
const spec = `${packageName}@${version}`;
|
|
187
|
+
let stdout;
|
|
188
|
+
try {
|
|
189
|
+
({ stdout } = await execa('npm', ['view', spec, 'dist.integrity', '--json'], {
|
|
190
|
+
cwd: STATE_DIR,
|
|
191
|
+
timeout: 60000,
|
|
192
|
+
}));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (/\bE404\b/.test(`${err.stderr ?? ''}\n${err.stdout ?? ''}`)) {
|
|
195
|
+
throw httpError(
|
|
196
|
+
`${spec} is not published on the registry the panel installs from — the agent package is published at the plugin's version`,
|
|
197
|
+
400,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
throw httpError(`Could not read ${spec} from the registry: ${npmFailureLine(err)}`, 502);
|
|
201
|
+
}
|
|
202
|
+
let integrity = null;
|
|
203
|
+
const text = stdout.trim();
|
|
204
|
+
if (text !== '') {
|
|
205
|
+
try {
|
|
206
|
+
integrity = JSON.parse(text);
|
|
207
|
+
} catch {
|
|
208
|
+
throw httpError(`The registry answered for ${spec} with something npm could not read`, 502);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (typeof integrity !== 'string' || integrity === '') {
|
|
212
|
+
throw httpError(
|
|
213
|
+
`${spec} has no integrity on the registry — Lamaste installs an agent half only when its tarball is pinned by a sha512 integrity`,
|
|
214
|
+
400,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (!AGENT_HALF_INTEGRITY_RE.test(integrity)) {
|
|
218
|
+
const algorithm = /^([a-z0-9]{1,16})-/.exec(integrity)?.[1] ?? null;
|
|
219
|
+
throw httpError(
|
|
220
|
+
algorithm !== null && algorithm !== 'sha512'
|
|
221
|
+
? `${spec} is published with a ${algorithm} integrity — Lamaste installs an agent half only when its tarball is pinned by a sha512 integrity`
|
|
222
|
+
: `${spec} has a malformed integrity on the registry — Lamaste installs an agent half only when its tarball is pinned by a sha512 integrity`,
|
|
223
|
+
400,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return integrity;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Pin the agent half a validated manifest declares for its version:
|
|
231
|
+
* `{ package, version, integrity }`, the integrity read from the registry
|
|
232
|
+
* ({@link readRegistryIntegrity}). Null when Lamaste installs no agent half
|
|
233
|
+
* (no `agent.package`).
|
|
234
|
+
*
|
|
235
|
+
* @param {import('@lamalibre/lamaste').ParsedManifest} manifest
|
|
236
|
+
* @returns {Promise<{ package: string, version: string, integrity: string } | null>}
|
|
237
|
+
*/
|
|
238
|
+
export async function pinAgentHalf(manifest) {
|
|
239
|
+
const declaration = agentHalfOf(manifest);
|
|
240
|
+
if (!declaration) return null;
|
|
241
|
+
if (!AgentHalfVersionSchema.safeParse(manifest.version).success) {
|
|
242
|
+
throw httpError(
|
|
243
|
+
`Version ${manifest.version} cannot version an agent half (semver without build metadata)`,
|
|
244
|
+
400,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const integrity = await readRegistryIntegrity(declaration.package, manifest.version);
|
|
248
|
+
return { package: declaration.package, version: manifest.version, integrity };
|
|
249
|
+
}
|