@lamalibre/create-lamaste 4.2.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +13 -8
- package/scripts/lamaste-priv +205 -2
- package/src/index.js +8 -4
- package/src/lib/node-version.js +114 -0
- package/src/lib/panel-pin.js +41 -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 +110 -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 +4 -4
- package/vendor/docs/02-guides/desktop-app-setup.md +11 -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 +8 -4
- package/vendor/docs/04-api-reference/tunnels.md +15 -13
- package/vendor/docs/05-operations/backup-and-restore.md +3 -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 +195 -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/cli/index.js +0 -0
- 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 +25 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/audit.js +60 -0
- package/vendor/server/src/commands/panel-pin.js +40 -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 +277 -34
- package/vendor/server/src/panel-identity.js +165 -0
- package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -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,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's lifecycle hooks on the relay: its relay hook set as installed,
|
|
3
|
+
* the administrator's approval of what Lamaste runs and installs for it,
|
|
4
|
+
* auto-approve, and the hook runner that holds the approved module.
|
|
5
|
+
*
|
|
6
|
+
* Approval binds bytes (see `hook-set.ts` in the core library): one digest
|
|
7
|
+
* (`approvalDigest`) covers the relay hook set — the normalised relay
|
|
8
|
+
* declarations and the SHA-256 of the relay entry module — and the agent
|
|
9
|
+
* half Lamaste installs onto the hosting agent — its package, version,
|
|
10
|
+
* tarball integrity, service and system packages. Approval is needed when
|
|
11
|
+
* either exists. Lamaste runs relay hooks, hosts the plugin and installs its
|
|
12
|
+
* agent half only while the installed version's digest is the approved
|
|
13
|
+
* one.
|
|
14
|
+
*
|
|
15
|
+
* The runner is about the relay part alone: it holds a root-owned copy of
|
|
16
|
+
* the approved module that lamaste-priv installed after checking its
|
|
17
|
+
* SHA-256, and its state (`installedDigest`) names the *relay hook set's*
|
|
18
|
+
* digest (`relayHookSetDigest`) — so a new agent half with an unchanged
|
|
19
|
+
* relay module leaves the runner current.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { execa } from 'execa';
|
|
25
|
+
import {
|
|
26
|
+
agentHalfOf,
|
|
27
|
+
agentHalfPreview,
|
|
28
|
+
agentHookSetOf,
|
|
29
|
+
approvalDigest,
|
|
30
|
+
derivePluginRoute,
|
|
31
|
+
hookEntrySha256,
|
|
32
|
+
relayHookSetDigest,
|
|
33
|
+
relayHookSetNeedsEntry,
|
|
34
|
+
relayHookSetOf,
|
|
35
|
+
relayHookSetPackVersions,
|
|
36
|
+
relayHookSetPreview,
|
|
37
|
+
} from '@lamalibre/lamaste';
|
|
38
|
+
import { HOOK_RUNNER_SOCKET_DIR } from '@lamalibre/lamaste/server';
|
|
39
|
+
import { httpError } from './app-error.js';
|
|
40
|
+
import { getStateDb } from './state-db.js';
|
|
41
|
+
import { recordAudit, recordAuditBestEffort } from './audit.js';
|
|
42
|
+
import { notifyAdminsBestEffort } from './notifications.js';
|
|
43
|
+
import { installedPackageDir, readInstalledHookModule } from './plugin-packages.js';
|
|
44
|
+
|
|
45
|
+
const PRIV = '/usr/local/sbin/lamaste-priv';
|
|
46
|
+
/** Where lamaste-priv puts each plugin's approved module and runner.env (root-owned, world-readable). */
|
|
47
|
+
const HOOKS_DIR = process.env.LAMALIBRE_LAMASTE_HOOKS_DIR || '/opt/lamalibre/lamaste/hooks';
|
|
48
|
+
/** A stuck runner is restarted at most this often. */
|
|
49
|
+
const RUNNER_RESTART_MIN_INTERVAL_MS = 10_000;
|
|
50
|
+
|
|
51
|
+
/** The directory of the runner sockets (systemd's; a test points it elsewhere). */
|
|
52
|
+
export function hookSocketDir() {
|
|
53
|
+
return process.env.LAMALIBRE_LAMASTE_HOOKS_SOCKET_DIR || HOOK_RUNNER_SOCKET_DIR;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// The runner manager: lamaste-priv on a relay, replaced in tests.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const privRunnerManager = {
|
|
61
|
+
async install(route, entrySha256, moduleBytes) {
|
|
62
|
+
await execa('sudo', [PRIV, 'hooks', 'install', route, entrySha256 ?? 'none'], {
|
|
63
|
+
...(moduleBytes ? { input: moduleBytes } : {}),
|
|
64
|
+
timeout: 60_000,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
async activate(route, entrySha256) {
|
|
68
|
+
await execa('sudo', [PRIV, 'hooks', 'activate', route, entrySha256], { timeout: 60_000 });
|
|
69
|
+
},
|
|
70
|
+
async remove(route) {
|
|
71
|
+
await execa('sudo', [PRIV, 'hooks', 'remove', route], { timeout: 60_000 });
|
|
72
|
+
},
|
|
73
|
+
async restart(route) {
|
|
74
|
+
await execa('sudo', [PRIV, 'hooks', 'restart', route], { timeout: 30_000 });
|
|
75
|
+
},
|
|
76
|
+
/** The entry SHA-256 the runner is set up with: null for shared hooks only, undefined when not installed. */
|
|
77
|
+
async installedEntry(route) {
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
text = await readFile(path.join(HOOKS_DIR, route, 'runner.env'), 'utf-8');
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const m = /^LAMASTE_HOOK_RUNNER_ARGS=--entry \S*\/([a-f0-9]{64})\.mjs$/m.exec(text);
|
|
85
|
+
return m ? m[1] : null;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let runnerManager = privRunnerManager;
|
|
90
|
+
|
|
91
|
+
/** Replace how runners are installed and restarted (tests run the runner themselves). */
|
|
92
|
+
export function setHookRunnerManagerForTests(manager) {
|
|
93
|
+
runnerManager = manager ?? privRunnerManager;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// State
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function rowToState(row) {
|
|
101
|
+
if (!row) return null;
|
|
102
|
+
return {
|
|
103
|
+
approvedDigest: row.approved_digest,
|
|
104
|
+
approvedVersion: row.approved_version,
|
|
105
|
+
approvedAt: row.approved_at,
|
|
106
|
+
approvedBy: row.approved_by,
|
|
107
|
+
autoApprove: row.auto_approve === 1,
|
|
108
|
+
installedDigest: row.installed_digest,
|
|
109
|
+
packVersions: JSON.parse(row.pack_versions || '{}'),
|
|
110
|
+
pendingUpdate: row.pending_update ? JSON.parse(row.pending_update) : null,
|
|
111
|
+
tunnelFallback: row.tunnel_fallback ? JSON.parse(row.tunnel_fallback) : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readHookState(plugin) {
|
|
116
|
+
const db = await getStateDb();
|
|
117
|
+
return rowToState(db.prepare('SELECT * FROM plugin_hook_sets WHERE plugin = ?').get(plugin));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function writeHookState(plugin, patch) {
|
|
121
|
+
const db = await getStateDb();
|
|
122
|
+
const current = (await readHookState(plugin)) ?? {
|
|
123
|
+
approvedDigest: null,
|
|
124
|
+
approvedVersion: null,
|
|
125
|
+
approvedAt: null,
|
|
126
|
+
approvedBy: null,
|
|
127
|
+
autoApprove: false,
|
|
128
|
+
installedDigest: null,
|
|
129
|
+
packVersions: {},
|
|
130
|
+
pendingUpdate: null,
|
|
131
|
+
tunnelFallback: null,
|
|
132
|
+
};
|
|
133
|
+
const next = { ...current, ...patch };
|
|
134
|
+
db.prepare(
|
|
135
|
+
`INSERT INTO plugin_hook_sets
|
|
136
|
+
(plugin, approved_digest, approved_version, approved_at, approved_by, auto_approve,
|
|
137
|
+
installed_digest, pack_versions, pending_update, tunnel_fallback)
|
|
138
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
139
|
+
ON CONFLICT(plugin) DO UPDATE SET
|
|
140
|
+
approved_digest = excluded.approved_digest,
|
|
141
|
+
approved_version = excluded.approved_version,
|
|
142
|
+
approved_at = excluded.approved_at,
|
|
143
|
+
approved_by = excluded.approved_by,
|
|
144
|
+
auto_approve = excluded.auto_approve,
|
|
145
|
+
installed_digest = excluded.installed_digest,
|
|
146
|
+
pack_versions = excluded.pack_versions,
|
|
147
|
+
pending_update = excluded.pending_update,
|
|
148
|
+
tunnel_fallback = excluded.tunnel_fallback`,
|
|
149
|
+
).run(
|
|
150
|
+
plugin,
|
|
151
|
+
next.approvedDigest,
|
|
152
|
+
next.approvedVersion,
|
|
153
|
+
next.approvedAt,
|
|
154
|
+
next.approvedBy,
|
|
155
|
+
next.autoApprove ? 1 : 0,
|
|
156
|
+
next.installedDigest,
|
|
157
|
+
JSON.stringify(next.packVersions ?? {}),
|
|
158
|
+
next.pendingUpdate ? JSON.stringify(next.pendingUpdate) : null,
|
|
159
|
+
next.tunnelFallback ? JSON.stringify(next.tunnelFallback) : null,
|
|
160
|
+
);
|
|
161
|
+
return next;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Put back a state read earlier with {@link readHookState} (reverting an update). */
|
|
165
|
+
export async function restoreHookState(plugin, state) {
|
|
166
|
+
if (state === null) {
|
|
167
|
+
await deleteHookState(plugin);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
await writeHookState(plugin, { ...state, pendingUpdate: null });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function deleteHookState(plugin) {
|
|
174
|
+
const db = await getStateDb();
|
|
175
|
+
db.prepare('DELETE FROM plugin_hook_sets WHERE plugin = ?').run(plugin);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// What an installed plugin brings, and its approval
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The relay hook set of an installed plugin (registry entry), with the
|
|
184
|
+
* digest of that set alone (`relayHookSetDigest` — what the runner holds);
|
|
185
|
+
* null when the plugin declares no relay hook.
|
|
186
|
+
*/
|
|
187
|
+
export function installedRelayHooks(plugin) {
|
|
188
|
+
const set = relayHookSetOf({ hooks: plugin.hooks });
|
|
189
|
+
if (!set) return null;
|
|
190
|
+
const entrySha256 = relayHookSetNeedsEntry(set) ? (plugin.hooksEntrySha256 ?? null) : null;
|
|
191
|
+
return { set, entrySha256, digest: relayHookSetDigest(set, entrySha256) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The agent half Lamaste installs for a plugin (registry entry, or a staged
|
|
196
|
+
* version in the same shape): the manifest's declaration with the version
|
|
197
|
+
* and tarball integrity pinned when that version was staged. Null when the
|
|
198
|
+
* manifest names no `agent.package`. A declaration without a pin for this
|
|
199
|
+
* version is refused — nothing unpinned is ever approved or installed.
|
|
200
|
+
*
|
|
201
|
+
* @returns {import('@lamalibre/lamaste').AgentHalfSpec | null}
|
|
202
|
+
*/
|
|
203
|
+
export function installedAgentHalf(plugin) {
|
|
204
|
+
const declaration = agentHalfOf({ agent: plugin.agent });
|
|
205
|
+
if (!declaration) return null;
|
|
206
|
+
const pin = plugin.agentHalfPin;
|
|
207
|
+
if (!pin || pin.package !== declaration.package || pin.version !== plugin.version) {
|
|
208
|
+
throw httpError(
|
|
209
|
+
`The agent half of "${plugin.name}" ${plugin.version} has no pinned integrity — reinstall the plugin`,
|
|
210
|
+
409,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return { ...declaration, version: pin.version, integrity: pin.integrity };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* What approving a plugin version covers — its relay hook set and the
|
|
218
|
+
* agent half Lamaste installs — and the digest the approval names. `digest`
|
|
219
|
+
* is null when there is nothing to approve.
|
|
220
|
+
*/
|
|
221
|
+
export function installedApproval(plugin) {
|
|
222
|
+
const relay = installedRelayHooks(plugin);
|
|
223
|
+
const agentHalf = installedAgentHalf(plugin);
|
|
224
|
+
const required = relay !== null || agentHalf !== null;
|
|
225
|
+
return {
|
|
226
|
+
relay,
|
|
227
|
+
agentHalf,
|
|
228
|
+
required,
|
|
229
|
+
digest: required
|
|
230
|
+
? approvalDigest({
|
|
231
|
+
relay: relay ? { set: relay.set, entrySha256: relay.entrySha256 } : null,
|
|
232
|
+
agentHalf,
|
|
233
|
+
})
|
|
234
|
+
: null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether `state` approves the installed version (nothing to approve counts as approved). */
|
|
239
|
+
function isApproved(approval, state) {
|
|
240
|
+
return !approval.required || state?.approvedDigest === approval.digest;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether the installed version is approved — it brings neither relay hooks
|
|
245
|
+
* nor an agent half Lamaste installs, or its digest is the approved one.
|
|
246
|
+
* Relay hooks run, and the plugin is hosted, only then.
|
|
247
|
+
*/
|
|
248
|
+
export async function installedVersionApproved(plugin) {
|
|
249
|
+
const approval = installedApproval(plugin);
|
|
250
|
+
if (!approval.required) return true;
|
|
251
|
+
return isApproved(approval, await readHookState(plugin.name));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** What the panel shows about a plugin's hooks, its agent half and their approval. */
|
|
255
|
+
export async function hookStatus(plugin) {
|
|
256
|
+
const approval = installedApproval(plugin);
|
|
257
|
+
const { relay, agentHalf } = approval;
|
|
258
|
+
const agent = agentHookSetOf({ hooks: plugin.hooks });
|
|
259
|
+
const state = await readHookState(plugin.name);
|
|
260
|
+
const approved = isApproved(approval, state);
|
|
261
|
+
return {
|
|
262
|
+
relay: relay ? relayHookSetPreview(relay.set, relay.entrySha256) : null,
|
|
263
|
+
agent,
|
|
264
|
+
agentHalf: agentHalf ? agentHalfPreview(derivePluginRoute(plugin.name), agentHalf) : null,
|
|
265
|
+
approval: {
|
|
266
|
+
required: approval.required,
|
|
267
|
+
state: !approval.required ? 'not-required' : approved ? 'approved' : 'pending',
|
|
268
|
+
// The digest to approve the installed version with (relay hooks and agent half).
|
|
269
|
+
digest: approval.digest,
|
|
270
|
+
approvedDigest: state?.approvedDigest ?? null,
|
|
271
|
+
approvedVersion: state?.approvedVersion ?? null,
|
|
272
|
+
approvedAt: state?.approvedAt ?? null,
|
|
273
|
+
approvedBy: state?.approvedBy ?? null,
|
|
274
|
+
},
|
|
275
|
+
autoApprove: state?.autoApprove ?? false,
|
|
276
|
+
runner: {
|
|
277
|
+
installedDigest: state?.installedDigest ?? null,
|
|
278
|
+
current: relay !== null && approved && state?.installedDigest === relay.digest,
|
|
279
|
+
},
|
|
280
|
+
pendingUpdate: state?.pendingUpdate ? pendingUpdateView(state.pendingUpdate) : null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A staged update waiting for approval as clients see it — in the hook
|
|
286
|
+
* status and in the update response alike: its version, the digest to
|
|
287
|
+
* approve it with, what it brings (its relay hook set and the agent half
|
|
288
|
+
* Lamaste installs, each null when it brings none), and — when the plugin
|
|
289
|
+
* has auto-approve on — why auto-approve did not cover it
|
|
290
|
+
* (`autoApproveWithheld`, null otherwise). The stored record names the
|
|
291
|
+
* relay hook set's preview `preview`.
|
|
292
|
+
*/
|
|
293
|
+
export function pendingUpdateView(pending) {
|
|
294
|
+
return {
|
|
295
|
+
version: pending.version,
|
|
296
|
+
digest: pending.digest,
|
|
297
|
+
stagedAt: pending.stagedAt,
|
|
298
|
+
relay: pending.preview ?? null,
|
|
299
|
+
agentHalf: pending.agentHalf ?? null,
|
|
300
|
+
autoApproveWithheld: pending.autoApproveWithheld ?? null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// The runner
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Put the plugin's approved relay hook module into its runner when the
|
|
310
|
+
* runner does not hold it — after an approval, an update, and at start-up
|
|
311
|
+
* (a redeploy may have reset the install directory). Reads the module from
|
|
312
|
+
* the installed package and refuses it when its SHA-256 is not the one the
|
|
313
|
+
* approval names. Returns whether anything changed.
|
|
314
|
+
*/
|
|
315
|
+
export async function ensureRelayHooksInstalled(plugin, logger) {
|
|
316
|
+
const approval = installedApproval(plugin);
|
|
317
|
+
const { relay } = approval;
|
|
318
|
+
if (!relay) return false;
|
|
319
|
+
const state = await readHookState(plugin.name);
|
|
320
|
+
if (!isApproved(approval, state)) return false;
|
|
321
|
+
const installedEntry = await runnerManager.installedEntry(plugin.name);
|
|
322
|
+
if (installedEntry !== undefined && installedEntry === relay.entrySha256) {
|
|
323
|
+
if (state.installedDigest !== relay.digest) {
|
|
324
|
+
await writeHookState(plugin.name, { installedDigest: relay.digest });
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
let moduleBytes = null;
|
|
329
|
+
if (relay.entrySha256 !== null) {
|
|
330
|
+
const dir = await installedPackageDir(plugin.packageName);
|
|
331
|
+
moduleBytes = await readInstalledHookModule(dir, relay.set.entry);
|
|
332
|
+
if (hookEntrySha256(moduleBytes) !== relay.entrySha256) {
|
|
333
|
+
throw httpError(
|
|
334
|
+
`The relay hook module of "${plugin.name}" changed on disk since it was approved — reinstall the plugin`,
|
|
335
|
+
409,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await runnerManager.install(plugin.name, relay.entrySha256, moduleBytes);
|
|
340
|
+
await writeHookState(plugin.name, { installedDigest: relay.digest });
|
|
341
|
+
logger?.info({ plugin: plugin.name, digest: relay.digest }, 'Relay hook runner installed');
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Put a module whose hook set is already approved into the plugin's runner
|
|
347
|
+
* — an update, before its package is installed (the new version's relay
|
|
348
|
+
* stages run from it) — and record that set (`digest`, the relay hook set's
|
|
349
|
+
* own digest) as the one the runner holds. `moduleBytes` null: shared hooks
|
|
350
|
+
* only.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} plugin
|
|
353
|
+
* @param {{ digest: string, entrySha256: string | null, moduleBytes: Buffer | null }} module
|
|
354
|
+
*/
|
|
355
|
+
export async function installRunnerModule(plugin, { digest, entrySha256, moduleBytes }) {
|
|
356
|
+
await runnerManager.install(plugin, entrySha256, moduleBytes);
|
|
357
|
+
await writeHookState(plugin, { installedDigest: digest });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Point the runner back at a module it kept (the previous one, of relay hook
|
|
362
|
+
* set `digest`) — reverting an update — and record that set as the one it
|
|
363
|
+
* holds again. lamaste-priv keeps the active module and the one before it.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} plugin
|
|
366
|
+
* @param {{ digest: string, entrySha256: string | null }} module
|
|
367
|
+
*/
|
|
368
|
+
export async function restoreRunnerModule(plugin, { digest, entrySha256 }) {
|
|
369
|
+
if (entrySha256 === null) await runnerManager.install(plugin, null, null);
|
|
370
|
+
else await runnerManager.activate(plugin, entrySha256);
|
|
371
|
+
await writeHookState(plugin, { installedDigest: digest });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Remove the plugin's runner, keeping its approval state. */
|
|
375
|
+
export async function removeRunner(plugin) {
|
|
376
|
+
if ((await runnerManager.installedEntry(plugin)) !== undefined)
|
|
377
|
+
await runnerManager.remove(plugin);
|
|
378
|
+
if (await readHookState(plugin)) await writeHookState(plugin, { installedDigest: null });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Remove the plugin's runner and its approval state (uninstall). */
|
|
382
|
+
export async function removeRelayHooks(plugin, logger) {
|
|
383
|
+
const state = await readHookState(plugin);
|
|
384
|
+
if (state?.installedDigest || (await runnerManager.installedEntry(plugin)) !== undefined) {
|
|
385
|
+
await runnerManager.remove(plugin);
|
|
386
|
+
logger?.info({ plugin }, 'Relay hook runner removed');
|
|
387
|
+
}
|
|
388
|
+
await deleteHookState(plugin);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const lastRestart = new Map();
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Restart a runner that let a hook overrun its deadline — a hook in a busy
|
|
395
|
+
* loop blocks the whole runner, so the next call must get a fresh one. At
|
|
396
|
+
* most once per {@link RUNNER_RESTART_MIN_INTERVAL_MS} per plugin.
|
|
397
|
+
*/
|
|
398
|
+
export async function restartRelayHookRunner(plugin, logger) {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
if (now - (lastRestart.get(plugin) ?? 0) < RUNNER_RESTART_MIN_INTERVAL_MS) return false;
|
|
401
|
+
lastRestart.set(plugin, now);
|
|
402
|
+
try {
|
|
403
|
+
await runnerManager.restart(plugin);
|
|
404
|
+
logger?.warn({ plugin }, 'Restarted a relay hook runner after a hook timed out');
|
|
405
|
+
return true;
|
|
406
|
+
} catch (err) {
|
|
407
|
+
logger?.error({ plugin, err: err.message }, 'Failed to restart the relay hook runner');
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Approval
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* An administrator approves what a plugin version brings — its relay hook
|
|
418
|
+
* set and the agent half Lamaste installs — with the digest they were
|
|
419
|
+
* shown. The digest must be the installed version's — or the pending
|
|
420
|
+
* update's, in which case the caller applies the update. Recorded in the
|
|
421
|
+
* audit log.
|
|
422
|
+
*
|
|
423
|
+
* @returns {Promise<{ target: 'installed' | 'update', digest: string }>}
|
|
424
|
+
*/
|
|
425
|
+
export async function approveHookSet(plugin, digest, { actor, certSerial, logger }) {
|
|
426
|
+
const approval = installedApproval(plugin);
|
|
427
|
+
const { relay } = approval;
|
|
428
|
+
const state = await readHookState(plugin.name);
|
|
429
|
+
const pending = state?.pendingUpdate ?? null;
|
|
430
|
+
const now = new Date().toISOString();
|
|
431
|
+
|
|
432
|
+
if (pending && pending.digest === digest) {
|
|
433
|
+
await recordAudit({
|
|
434
|
+
actor,
|
|
435
|
+
action: 'hooks.approved',
|
|
436
|
+
plugin: plugin.name,
|
|
437
|
+
details: { digest, version: pending.version, for: 'update', certSerial },
|
|
438
|
+
});
|
|
439
|
+
return { target: 'update', digest };
|
|
440
|
+
}
|
|
441
|
+
if (!approval.required) {
|
|
442
|
+
throw httpError(
|
|
443
|
+
`Plugin "${plugin.name}" has no relay hooks and no agent half Lamaste installs — nothing to approve`,
|
|
444
|
+
400,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (approval.digest !== digest) {
|
|
448
|
+
throw httpError('The hook set changed since it was shown — review it again', 409, {
|
|
449
|
+
currentDigest: approval.digest,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
await writeHookState(plugin.name, {
|
|
453
|
+
approvedDigest: digest,
|
|
454
|
+
approvedVersion: plugin.version,
|
|
455
|
+
approvedAt: now,
|
|
456
|
+
approvedBy: actor,
|
|
457
|
+
packVersions: relay ? relayHookSetPackVersions(relay.set) : {},
|
|
458
|
+
});
|
|
459
|
+
await recordAudit({
|
|
460
|
+
actor,
|
|
461
|
+
action: 'hooks.approved',
|
|
462
|
+
plugin: plugin.name,
|
|
463
|
+
details: { digest, version: plugin.version, for: 'installed', certSerial },
|
|
464
|
+
});
|
|
465
|
+
await ensureRelayHooksInstalled(plugin, logger);
|
|
466
|
+
return { target: 'installed', digest };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Record an approval of an applied update (by an admin or auto-approve):
|
|
471
|
+
* `digest` is the version's approval digest (`installedApproval`).
|
|
472
|
+
*/
|
|
473
|
+
export async function recordApprovedSet(plugin, digest, version, approvedBy) {
|
|
474
|
+
const relay = installedRelayHooks(plugin);
|
|
475
|
+
await writeHookState(plugin.name, {
|
|
476
|
+
approvedDigest: digest,
|
|
477
|
+
approvedVersion: version,
|
|
478
|
+
approvedAt: new Date().toISOString(),
|
|
479
|
+
approvedBy,
|
|
480
|
+
pendingUpdate: null,
|
|
481
|
+
packVersions: relay ? relayHookSetPackVersions(relay.set) : {},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function setPendingUpdate(plugin, pendingUpdate) {
|
|
486
|
+
await writeHookState(plugin, { pendingUpdate });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The fallback the plugin's `tunnel:down` hook returned (null clears it, on `tunnel:up`). */
|
|
490
|
+
export async function setTunnelFallback(plugin, fallback) {
|
|
491
|
+
await writeHookState(plugin, { tunnelFallback: fallback ?? null });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function setAutoApprove(plugin, enabled, { actor, certSerial }) {
|
|
495
|
+
const state = await readHookState(plugin);
|
|
496
|
+
if ((state?.autoApprove ?? false) === enabled) return { autoApprove: enabled };
|
|
497
|
+
await writeHookState(plugin, { autoApprove: enabled });
|
|
498
|
+
await recordAudit({
|
|
499
|
+
actor,
|
|
500
|
+
action: enabled ? 'hooks.auto-approve.enabled' : 'hooks.auto-approve.disabled',
|
|
501
|
+
plugin,
|
|
502
|
+
details: { certSerial },
|
|
503
|
+
});
|
|
504
|
+
return { autoApprove: enabled };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Start-up
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* At start-up: make sure each approved runner holds its module, and audit
|
|
513
|
+
* — once per plugin — a shared-hook rule pack that changed with this Lamaste
|
|
514
|
+
* version (rules ride Lamaste releases and need no re-approval).
|
|
515
|
+
*/
|
|
516
|
+
export async function reconcileRelayHooks(plugins, logger) {
|
|
517
|
+
for (const plugin of plugins) {
|
|
518
|
+
try {
|
|
519
|
+
const approval = installedApproval(plugin);
|
|
520
|
+
const { relay } = approval;
|
|
521
|
+
if (!relay) continue;
|
|
522
|
+
const state = await readHookState(plugin.name);
|
|
523
|
+
if (!isApproved(approval, state)) {
|
|
524
|
+
logger?.info({ plugin: plugin.name }, 'Relay hooks wait for approval');
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
await ensureRelayHooksInstalled(plugin, logger);
|
|
528
|
+
const packs = relayHookSetPackVersions(relay.set);
|
|
529
|
+
const before = state.packVersions ?? {};
|
|
530
|
+
const changed = Object.keys(packs).filter((p) => before[p] !== packs[p]);
|
|
531
|
+
if (changed.length > 0) {
|
|
532
|
+
await writeHookState(plugin.name, { packVersions: packs });
|
|
533
|
+
const from = changed.map((p) => `${p}@${before[p] ?? '-'}`).join(',');
|
|
534
|
+
const to = changed.map((p) => `${p}@${packs[p]}`).join(',');
|
|
535
|
+
await recordAuditBestEffort(
|
|
536
|
+
{
|
|
537
|
+
actor: 'lamaste',
|
|
538
|
+
action: 'hooks.rules-updated',
|
|
539
|
+
plugin: plugin.name,
|
|
540
|
+
details: { from, to },
|
|
541
|
+
},
|
|
542
|
+
logger,
|
|
543
|
+
);
|
|
544
|
+
await notifyAdminsBestEffort(
|
|
545
|
+
{
|
|
546
|
+
source: 'lamaste',
|
|
547
|
+
level: 'info',
|
|
548
|
+
message: `Secret-scan rules used by ${plugin.name} were updated with Lamaste (${to}).`,
|
|
549
|
+
},
|
|
550
|
+
logger,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
logger?.error(
|
|
555
|
+
{ plugin: plugin.name, err: err.message },
|
|
556
|
+
'Failed to reconcile the relay hook runner',
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|