@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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Lamaste</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DBp3cmaU.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bc5NCMSv.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body style="background-color: #09090b">
|
|
11
11
|
<div id="root"></div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamalibre/lamaste-serverd",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Lamaste server daemon — REST API and plugin host",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"publishConfig": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@fastify/rate-limit": "^10.3.0",
|
|
30
30
|
"@fastify/static": "^9.1.1",
|
|
31
31
|
"@fastify/websocket": "^11.0.0",
|
|
32
|
-
"@lamalibre/lamaste": "^4.
|
|
32
|
+
"@lamalibre/lamaste": "^4.3.0",
|
|
33
33
|
"bcryptjs": "^3.0.3",
|
|
34
34
|
"execa": "^9.6.1",
|
|
35
35
|
"fastify": "^5.8.4",
|
|
@@ -17,6 +17,7 @@ import healthRoutes from './routes/health.js';
|
|
|
17
17
|
import onboardingRoutes from './routes/onboarding/index.js';
|
|
18
18
|
import managementRoutes from './routes/management.js';
|
|
19
19
|
import pluginRouter from './routes/plugin-router.js';
|
|
20
|
+
import agentForwarderRoutes from './routes/agent-forwarder.js';
|
|
20
21
|
import inviteRoutes from './routes/invite.js';
|
|
21
22
|
import enrollmentRoutes from './routes/enrollment.js';
|
|
22
23
|
import {
|
|
@@ -25,10 +26,20 @@ import {
|
|
|
25
26
|
userAccessProtectedRoutes,
|
|
26
27
|
} from './routes/user-access.js';
|
|
27
28
|
import userAccessSessionMiddleware from './middleware/user-access-session.js';
|
|
28
|
-
import { getPluginCapabilities } from './lib/plugins.js';
|
|
29
|
+
import { getPluginCapabilities, readPlugins } from './lib/plugins.js';
|
|
30
|
+
import { markInterruptedOperations } from './lib/plugin-operations.js';
|
|
31
|
+
import { trustLocalNginxHop } from './lib/trust-proxy.js';
|
|
32
|
+
import { reconcileRelayHooks } from './lib/plugin-hooks.js';
|
|
33
|
+
import { loadTunnelFallbacks } from './lib/request-hooks.js';
|
|
29
34
|
import { setPluginCapabilities } from './lib/mtls.js';
|
|
30
35
|
import { startChiselReconciler } from './lib/chisel-reconcile.js';
|
|
36
|
+
import {
|
|
37
|
+
startHostedPluginHealthMonitor,
|
|
38
|
+
stopHostedPluginHealthMonitor,
|
|
39
|
+
} from './lib/plugin-hosting.js';
|
|
31
40
|
import { syncOidcClients } from './lib/plugin-oidc.js';
|
|
41
|
+
import { ensureRelaySigningKey } from './lib/relay-signing.js';
|
|
42
|
+
import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
|
|
32
43
|
import {
|
|
33
44
|
loadTicketScopeCapabilities,
|
|
34
45
|
checkInstanceLiveness,
|
|
@@ -61,9 +72,10 @@ async function start() {
|
|
|
61
72
|
|
|
62
73
|
const server = Fastify({
|
|
63
74
|
logger: true,
|
|
64
|
-
// Behind nginx: trust exactly one
|
|
65
|
-
//
|
|
66
|
-
|
|
75
|
+
// Behind the local nginx: trust exactly that one loopback hop, so
|
|
76
|
+
// request.ip is the client address nginx appended to X-Forwarded-For —
|
|
77
|
+
// not a value the client sent, and nothing from a non-local peer.
|
|
78
|
+
trustProxy: trustLocalNginxHop,
|
|
67
79
|
});
|
|
68
80
|
|
|
69
81
|
// Wire ticket module to the Fastify root logger so any load-time warnings
|
|
@@ -87,6 +99,11 @@ async function start() {
|
|
|
87
99
|
});
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
// The key relay assertions to agents' lamaste-agentd are signed with —
|
|
103
|
+
// created here when missing, before Gatekeeper (which signs with it too)
|
|
104
|
+
// needs it.
|
|
105
|
+
await ensureRelaySigningKey(server.log);
|
|
106
|
+
|
|
90
107
|
// --- Plugins ---
|
|
91
108
|
const ipOrigin = `https://${config.ip}:9292`;
|
|
92
109
|
await server.register(cors, {
|
|
@@ -158,6 +175,9 @@ async function start() {
|
|
|
158
175
|
app.register(managementRoutes, { prefix: '/api' });
|
|
159
176
|
app.register(userAccessAdminRoutes, { prefix: '/api' });
|
|
160
177
|
app.register(pluginRouter, { prefix: '/api' });
|
|
178
|
+
// Last: agent-hosted plugins with an agent half, forwarded over their
|
|
179
|
+
// internal tunnels. Only what no other /api route answers reaches it.
|
|
180
|
+
app.register(agentForwarderRoutes, { prefix: '/api' });
|
|
161
181
|
});
|
|
162
182
|
|
|
163
183
|
// --- User-access session-protected routes (Bearer token, no mTLS) ---
|
|
@@ -176,8 +196,39 @@ async function start() {
|
|
|
176
196
|
});
|
|
177
197
|
|
|
178
198
|
// --- Start ---
|
|
199
|
+
|
|
200
|
+
// The auth portal and static site vhosts an older version wrote name the
|
|
201
|
+
// client's Host header, not their own hostname, to Authelia. Before listen:
|
|
202
|
+
// site changes take no lock, and none can arrive until the panel listens.
|
|
203
|
+
if (config.onboarding.status === 'COMPLETED' && config.domain) {
|
|
204
|
+
await reconcileSiteVhosts(server.log, config.domain).catch((err) => {
|
|
205
|
+
server.log.error({ err: err.message }, 'Startup site vhost reconcile failed');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
179
209
|
await server.listen({ host: '127.0.0.1', port: 3100 });
|
|
180
210
|
|
|
211
|
+
// --- Lifecycle hooks: operations a restart cut short, the runners of
|
|
212
|
+
// approved relay hook sets (a redeploy may have reset them), fallbacks ---
|
|
213
|
+
await markInterruptedOperations(server.log);
|
|
214
|
+
await loadTunnelFallbacks();
|
|
215
|
+
readPlugins()
|
|
216
|
+
.then(({ plugins }) => reconcileRelayHooks(plugins, server.log))
|
|
217
|
+
.catch((err) => server.log.error({ err: err.message }, 'Relay hook reconciliation failed'));
|
|
218
|
+
|
|
219
|
+
// Tunnel vhosts written by an older version: agent-panel and plugin vhosts
|
|
220
|
+
// do not forward relay assertions, without which lamaste-agentd refuses the
|
|
221
|
+
// relay, and Gatekeeper vhosts name the client's Host header to Gatekeeper.
|
|
222
|
+
// After listen: a rewritten agent-panel vhost asks this server at once.
|
|
223
|
+
if (config.onboarding.status === 'COMPLETED') {
|
|
224
|
+
reconcileTunnelVhosts(server.log).catch((err) => {
|
|
225
|
+
server.log.error({ err: err.message }, 'Startup tunnel vhost reconcile failed');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
|
|
230
|
+
startHostedPluginHealthMonitor(server.log);
|
|
231
|
+
|
|
181
232
|
// --- Periodic ticket instance liveness check (every 60s) ---
|
|
182
233
|
const livenessInterval = setInterval(() => {
|
|
183
234
|
checkInstanceLiveness(server.log).catch((err) => {
|
|
@@ -190,6 +241,7 @@ async function start() {
|
|
|
190
241
|
const shutdown = async (signal) => {
|
|
191
242
|
server.log.info({ signal }, 'Received signal, shutting down gracefully');
|
|
192
243
|
clearInterval(livenessInterval);
|
|
244
|
+
stopHostedPluginHealthMonitor();
|
|
193
245
|
clearRateLimitInterval();
|
|
194
246
|
await server.close();
|
|
195
247
|
process.exit(0);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a stable audit identifier for the calling principal.
|
|
3
|
+
*
|
|
4
|
+
* Returns the certificate CN form (`admin`, `agent:<label>`, `plugin-agent:...`)
|
|
5
|
+
* so a reader of the journal or the audit log can correlate events with the
|
|
6
|
+
* principal that initiated them. Returns `'unknown'` when middleware did not
|
|
7
|
+
* populate cert metadata (e.g. dev mode shortcut on /api/health).
|
|
8
|
+
*/
|
|
9
|
+
export function actorFromRequest(request) {
|
|
10
|
+
if (request.certRole === 'admin') return 'admin';
|
|
11
|
+
if (request.certRole === 'agent' && request.certLabel) return `agent:${request.certLabel}`;
|
|
12
|
+
if (request.certRole === 'plugin-agent' && request.certLabel) return request.certLabel;
|
|
13
|
+
return 'unknown';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The client certificate serial nginx reported, for audit entries: every
|
|
18
|
+
* administrator shares the `admin` identity, so the serial is what tells two
|
|
19
|
+
* admin certificates apart. Not secret. Null when absent (development mode).
|
|
20
|
+
*/
|
|
21
|
+
export function certSerialFromRequest(request) {
|
|
22
|
+
const serial = request.headers?.['x-ssl-client-serial'];
|
|
23
|
+
return typeof serial === 'string' && /^[0-9A-Fa-f]{1,64}$/.test(serial) ? serial : null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent jobs — the panel's way to have an agent do something on its own
|
|
3
|
+
* machine and report back. The panel cannot reach an agent (agents connect
|
|
4
|
+
* out, behind NAT); an agent's sync timer fetches `agent-config` every 30 s,
|
|
5
|
+
* which lists the agent's pending jobs, runs them, and posts each result to
|
|
6
|
+
* `POST /api/agents/me/jobs/:id/result`.
|
|
7
|
+
*
|
|
8
|
+
* The kinds (core `agent-jobs.ts` has the result contract both ends check):
|
|
9
|
+
*
|
|
10
|
+
* - `hook` — call the agent half's `POST /admin/hooks/<stage>` on the
|
|
11
|
+
* agent's loopback, for the lifecycle stages that run while the internal
|
|
12
|
+
* tunnel is not there (the agent's `install:pre-tunnel` before it is
|
|
13
|
+
* granted, `uninstall:post-teardown` after it is removed). The agent
|
|
14
|
+
* signs that call with the installation secret it already holds.
|
|
15
|
+
* - `app-install` (`spec`: the pinned agent half), `app-activate`
|
|
16
|
+
* (`version`, `package`) and `app-remove` (`purge`, `package`) — install,
|
|
17
|
+
* switch or remove the agent half through the agent's root helper
|
|
18
|
+
* `lamaste-apphost`. The agent fetches the package itself and installs it
|
|
19
|
+
* only when its tarball matches the pinned integrity.
|
|
20
|
+
*
|
|
21
|
+
* The helper runs as root and does only what the relay signed: every app
|
|
22
|
+
* job is signed with the relay's signing key when it is created (core
|
|
23
|
+
* `app-job-signature.ts`; the envelope names the agent, route, job id, kind
|
|
24
|
+
* and payload, and expires at the job's deadline) and listed with its
|
|
25
|
+
* signature. No app job is created while the key is unusable.
|
|
26
|
+
*
|
|
27
|
+
* A job carries no secret. It has a deadline (its timeout plus delivery
|
|
28
|
+
* slack); an unfinished job past it expires. Waiting is event-driven within
|
|
29
|
+
* this process, with the database as the record.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import { EventEmitter } from 'node:events';
|
|
34
|
+
import {
|
|
35
|
+
AGENT_APP_JOB_TIMEOUTS,
|
|
36
|
+
AgentAppJobResultSchema,
|
|
37
|
+
AgentHookJobResultSchema,
|
|
38
|
+
appJobPayload,
|
|
39
|
+
signAppJob,
|
|
40
|
+
} from '@lamalibre/lamaste';
|
|
41
|
+
import { relaySigner } from './relay-signing.js';
|
|
42
|
+
import { getStateDb } from './state-db.js';
|
|
43
|
+
|
|
44
|
+
/** Two sync intervals and a margin: how long delivery may take on top of the hook's own timeout. */
|
|
45
|
+
export const AGENT_JOB_DELIVERY_SLACK_MS = 90_000;
|
|
46
|
+
|
|
47
|
+
/** Largest result an agent may post. */
|
|
48
|
+
export const AGENT_JOB_RESULT_MAX_BYTES = 64 * 1024;
|
|
49
|
+
|
|
50
|
+
/** The app job kinds and how long the agent has for each (the helper's work, not delivery). */
|
|
51
|
+
export const AGENT_APP_JOB_KINDS = Object.freeze({
|
|
52
|
+
'app-install': AGENT_APP_JOB_TIMEOUTS.install,
|
|
53
|
+
'app-activate': AGENT_APP_JOB_TIMEOUTS.activate,
|
|
54
|
+
'app-remove': AGENT_APP_JOB_TIMEOUTS.remove,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const events = new EventEmitter();
|
|
58
|
+
events.setMaxListeners(0);
|
|
59
|
+
|
|
60
|
+
/** An app job could not be signed: the relay's signing key is missing or unusable. */
|
|
61
|
+
export class AppJobSigningError extends Error {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'AppJobSigningError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function rowToJob(row) {
|
|
69
|
+
return {
|
|
70
|
+
id: row.id,
|
|
71
|
+
agentLabel: row.agent_label,
|
|
72
|
+
route: row.plugin,
|
|
73
|
+
kind: row.kind,
|
|
74
|
+
stage: row.stage,
|
|
75
|
+
input: row.input ? JSON.parse(row.input) : null,
|
|
76
|
+
payload: row.payload ? JSON.parse(row.payload) : null,
|
|
77
|
+
signature: row.signature ? JSON.parse(row.signature) : null,
|
|
78
|
+
timeoutMs: row.timeout_ms,
|
|
79
|
+
status: row.status,
|
|
80
|
+
result: row.result ? JSON.parse(row.result) : null,
|
|
81
|
+
createdAt: row.created_at,
|
|
82
|
+
deadlineAt: row.deadline_at,
|
|
83
|
+
completedAt: row.completed_at,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Insert a pending job: `stage` and `input` for a hook job, `payload` (and
|
|
89
|
+
* `sign`, which makes its signature) for an app job; null otherwise. Its
|
|
90
|
+
* deadline leaves delivery {@link AGENT_JOB_DELIVERY_SLACK_MS} on top of
|
|
91
|
+
* `timeoutMs`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} job
|
|
94
|
+
* @param {((timing: { id: string, issuedAt: string, expiresAt: string }) => object) | null} [job.sign]
|
|
95
|
+
* @returns {Promise<string>} the job id
|
|
96
|
+
*/
|
|
97
|
+
async function insertJob({ agentLabel, plugin, kind, stage, input, payload, timeoutMs, sign }) {
|
|
98
|
+
const db = await getStateDb();
|
|
99
|
+
const id = crypto.randomUUID();
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const createdAt = new Date(now).toISOString();
|
|
102
|
+
const deadlineAt = new Date(now + timeoutMs + AGENT_JOB_DELIVERY_SLACK_MS).toISOString();
|
|
103
|
+
const signature = sign ? sign({ id, issuedAt: createdAt, expiresAt: deadlineAt }) : null;
|
|
104
|
+
db.prepare(
|
|
105
|
+
`INSERT INTO agent_jobs (id, agent_label, plugin, kind, stage, input, payload, signature, timeout_ms, status, created_at, deadline_at)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
|
107
|
+
).run(
|
|
108
|
+
id,
|
|
109
|
+
agentLabel,
|
|
110
|
+
plugin,
|
|
111
|
+
kind,
|
|
112
|
+
stage,
|
|
113
|
+
input,
|
|
114
|
+
payload,
|
|
115
|
+
signature === null ? null : JSON.stringify(signature),
|
|
116
|
+
timeoutMs,
|
|
117
|
+
createdAt,
|
|
118
|
+
deadlineAt,
|
|
119
|
+
);
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A `hook` job: call the agent half's `stage` hook on the agent's loopback.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ agentLabel: string, plugin: string, stage: string, input: object, timeoutMs: number }} job
|
|
127
|
+
* @returns {Promise<string>} the job id
|
|
128
|
+
*/
|
|
129
|
+
export async function createAgentJob({ agentLabel, plugin, stage, input, timeoutMs }) {
|
|
130
|
+
return insertJob({
|
|
131
|
+
agentLabel,
|
|
132
|
+
plugin,
|
|
133
|
+
kind: 'hook',
|
|
134
|
+
stage,
|
|
135
|
+
input: JSON.stringify(input),
|
|
136
|
+
payload: null,
|
|
137
|
+
timeoutMs,
|
|
138
|
+
sign: null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The relay's signing key for an app job; throws {@link AppJobSigningError}
|
|
144
|
+
* when there is none to use — the agent's helper would refuse the job.
|
|
145
|
+
*/
|
|
146
|
+
async function appJobSigningKey() {
|
|
147
|
+
let key;
|
|
148
|
+
try {
|
|
149
|
+
key = await relaySigner.current();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new AppJobSigningError(
|
|
152
|
+
`The relay's signing key is unusable (${err instanceof Error ? err.message : String(err)}), ` +
|
|
153
|
+
"so it cannot sign the job the agent's app host helper would run",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (!key) {
|
|
157
|
+
throw new AppJobSigningError(
|
|
158
|
+
"The relay has no signing key, so it cannot sign the job the agent's app host helper would run",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return key;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* An app job: install (`spec`), activate (`version`, `package`) or remove
|
|
166
|
+
* (`purge`, `package`) the plugin's agent half through the agent's root
|
|
167
|
+
* helper — signed with the relay's key for the agent and the job's
|
|
168
|
+
* deadline. Throws {@link AppJobSigningError} (nothing is created) when the
|
|
169
|
+
* key is unusable, a TypeError for parameters that are not the kind's.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ agentLabel: string, plugin: string, kind: 'app-install' | 'app-activate' | 'app-remove', spec?: object, version?: string, purge?: boolean, package?: string }} job
|
|
172
|
+
* @returns {Promise<string>} the job id
|
|
173
|
+
*/
|
|
174
|
+
export async function createAppJob({ agentLabel, plugin, kind, ...params }) {
|
|
175
|
+
if (!Object.hasOwn(AGENT_APP_JOB_KINDS, kind)) {
|
|
176
|
+
throw new TypeError(`Unknown app job kind "${kind}"`);
|
|
177
|
+
}
|
|
178
|
+
const payload = appJobPayload(kind, params);
|
|
179
|
+
const key = await appJobSigningKey();
|
|
180
|
+
return insertJob({
|
|
181
|
+
agentLabel,
|
|
182
|
+
plugin,
|
|
183
|
+
kind,
|
|
184
|
+
stage: null,
|
|
185
|
+
input: null,
|
|
186
|
+
payload: JSON.stringify(payload),
|
|
187
|
+
timeoutMs: AGENT_APP_JOB_KINDS[kind],
|
|
188
|
+
sign: ({ id, issuedAt, expiresAt }) =>
|
|
189
|
+
signAppJob(
|
|
190
|
+
{ agent: agentLabel, route: plugin, jobId: id, kind, payload, issuedAt, expiresAt },
|
|
191
|
+
key,
|
|
192
|
+
),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Finish the pending jobs `rows` as `status` (expired, cancelled) and wake their waiters. */
|
|
197
|
+
function finishPending(db, rows, status, now) {
|
|
198
|
+
const finish = db.prepare(
|
|
199
|
+
"UPDATE agent_jobs SET status = ?, completed_at = ? WHERE id = ? AND status = 'pending'",
|
|
200
|
+
);
|
|
201
|
+
for (const { id } of rows) {
|
|
202
|
+
finish.run(status, now, id);
|
|
203
|
+
events.emit(id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Expire jobs past their deadline. */
|
|
208
|
+
async function expireOverdue(db) {
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
const overdue = db
|
|
211
|
+
.prepare("SELECT id FROM agent_jobs WHERE status = 'pending' AND deadline_at <= ?")
|
|
212
|
+
.all(now);
|
|
213
|
+
finishPending(db, overdue, 'expired', now);
|
|
214
|
+
// Finished jobs are kept a day for the record, then dropped.
|
|
215
|
+
const cutoff = new Date(Date.now() - 86_400_000).toISOString();
|
|
216
|
+
db.prepare("DELETE FROM agent_jobs WHERE status != 'pending' AND created_at < ?").run(cutoff);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The agent-config `agentJobs` of agent `label`: pending, not yet overdue.
|
|
221
|
+
* A `hook` job carries `stage` and `input`; an app job its parameters
|
|
222
|
+
* (`spec`; `version` and `package`; `purge` and `package`) and the relay's
|
|
223
|
+
* `signature` over them.
|
|
224
|
+
*/
|
|
225
|
+
export async function pendingAgentJobs(label) {
|
|
226
|
+
const db = await getStateDb();
|
|
227
|
+
await expireOverdue(db);
|
|
228
|
+
return db
|
|
229
|
+
.prepare(
|
|
230
|
+
"SELECT * FROM agent_jobs WHERE agent_label = ? AND status = 'pending' ORDER BY created_at",
|
|
231
|
+
)
|
|
232
|
+
.all(label)
|
|
233
|
+
.map((row) => {
|
|
234
|
+
const job = rowToJob(row);
|
|
235
|
+
const base = { id: job.id, route: job.route, kind: job.kind };
|
|
236
|
+
const timing = { timeoutMs: job.timeoutMs, deadlineAt: job.deadlineAt };
|
|
237
|
+
return job.kind === 'hook'
|
|
238
|
+
? { ...base, stage: job.stage, input: job.input, ...timing }
|
|
239
|
+
: { ...base, ...timing, ...job.payload, signature: job.signature };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One job by id, or null. */
|
|
244
|
+
export async function findAgentJob(id) {
|
|
245
|
+
const db = await getStateDb();
|
|
246
|
+
const row = db.prepare('SELECT * FROM agent_jobs WHERE id = ?').get(id);
|
|
247
|
+
return row ? rowToJob(row) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The agent reports a job's result. Only the agent the job is for may, and
|
|
252
|
+
* only once. The result must fit the job's kind (core `AgentJobResultSchema`):
|
|
253
|
+
* a hook job's `{ ok: true, decision }`, an app job's
|
|
254
|
+
* `{ ok: true, app: { version, unit } }`, or `{ ok: false, error }` (one
|
|
255
|
+
* line). What is stored is the validated result.
|
|
256
|
+
*
|
|
257
|
+
* @returns {Promise<'recorded' | 'not_found' | 'not_pending' | 'invalid'>}
|
|
258
|
+
*/
|
|
259
|
+
export async function completeAgentJob(label, id, result) {
|
|
260
|
+
const db = await getStateDb();
|
|
261
|
+
const row = db
|
|
262
|
+
.prepare('SELECT * FROM agent_jobs WHERE id = ? AND agent_label = ?')
|
|
263
|
+
.get(id, label);
|
|
264
|
+
if (!row) return 'not_found';
|
|
265
|
+
if (row.status !== 'pending') return 'not_pending';
|
|
266
|
+
const schema = row.kind === 'hook' ? AgentHookJobResultSchema : AgentAppJobResultSchema;
|
|
267
|
+
const parsed = schema.safeParse(result);
|
|
268
|
+
if (!parsed.success) return 'invalid';
|
|
269
|
+
const changes = db
|
|
270
|
+
.prepare(
|
|
271
|
+
"UPDATE agent_jobs SET status = 'done', result = ?, completed_at = ? WHERE id = ? AND status = 'pending'",
|
|
272
|
+
)
|
|
273
|
+
.run(JSON.stringify(parsed.data), new Date().toISOString(), id).changes;
|
|
274
|
+
if (Number(changes) === 0) return 'not_pending';
|
|
275
|
+
events.emit(id);
|
|
276
|
+
return 'recorded';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Wait for a job to finish. Resolves the job's result — `{ ok: true, decision }`
|
|
281
|
+
* (hook) or `{ ok: true, app }` (app job) — `{ ok: false, error }` (the
|
|
282
|
+
* agent reported a failure), or `{ ok: false, error, expired: true }` when
|
|
283
|
+
* the deadline passed (or the job was cancelled): the outcome is unknown.
|
|
284
|
+
*/
|
|
285
|
+
export async function waitForAgentJob(id) {
|
|
286
|
+
const db = await getStateDb();
|
|
287
|
+
for (;;) {
|
|
288
|
+
const row = db.prepare('SELECT * FROM agent_jobs WHERE id = ?').get(id);
|
|
289
|
+
if (!row) return { ok: false, error: 'The job no longer exists' };
|
|
290
|
+
if (row.status === 'done') {
|
|
291
|
+
const result = JSON.parse(row.result);
|
|
292
|
+
return result && typeof result === 'object'
|
|
293
|
+
? result
|
|
294
|
+
: { ok: false, error: 'Malformed result' };
|
|
295
|
+
}
|
|
296
|
+
if (row.status === 'expired' || row.status === 'cancelled') {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
expired: true,
|
|
300
|
+
error:
|
|
301
|
+
row.status === 'expired'
|
|
302
|
+
? 'The agent did not report the job before its deadline (offline, or its sync is not running)'
|
|
303
|
+
: 'The job was cancelled',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const untilDeadline = Date.parse(row.deadline_at) - Date.now();
|
|
307
|
+
if (untilDeadline <= 0) {
|
|
308
|
+
await expireOverdue(db);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
await new Promise((resolve) => {
|
|
312
|
+
const timer = setTimeout(done, Math.min(untilDeadline + 10, 5_000));
|
|
313
|
+
function done() {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
events.off(id, done);
|
|
316
|
+
resolve();
|
|
317
|
+
}
|
|
318
|
+
events.once(id, done);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Cancel a plugin's pending jobs (its operation ended). */
|
|
324
|
+
export async function cancelAgentJobs(plugin) {
|
|
325
|
+
const db = await getStateDb();
|
|
326
|
+
const rows = db
|
|
327
|
+
.prepare("SELECT id FROM agent_jobs WHERE plugin = ? AND status = 'pending'")
|
|
328
|
+
.all(plugin);
|
|
329
|
+
finishPending(db, rows, 'cancelled', new Date().toISOString());
|
|
330
|
+
}
|
|
@@ -12,3 +12,13 @@ export class AppError extends Error {
|
|
|
12
12
|
this.isOperational = true;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A plain Error carrying the HTTP `statusCode` a route answers with, and
|
|
18
|
+
* whatever `extra` names beyond the message (a refusal's `code`,
|
|
19
|
+
* `reasons`, …). Not an {@link AppError}: the error middleware treats those
|
|
20
|
+
* as operational.
|
|
21
|
+
*/
|
|
22
|
+
export function httpError(message, statusCode, extra = {}) {
|
|
23
|
+
return Object.assign(new Error(message), { statusCode, ...extra });
|
|
24
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Lamaste's audit log — an append-only record in state.db of administrative
|
|
2
|
+
// decisions (hook approvals, auto-approve changes, hostings, plugin updates)
|
|
3
|
+
// and of what plugins' lifecycle hooks report through the `audit` effect.
|
|
4
|
+
//
|
|
5
|
+
// Entries hold metadata only. `details` is a flat-ish JSON object capped at
|
|
6
|
+
// AUDIT_DETAILS_MAX_BYTES; strings in it are truncated. Nothing here is ever
|
|
7
|
+
// a request or response body or a secret — callers pass identifiers, counts
|
|
8
|
+
// and reasons. The log is pruned by age and count, never edited.
|
|
9
|
+
|
|
10
|
+
import { getStateDb } from './state-db.js';
|
|
11
|
+
|
|
12
|
+
export const AUDIT_RETENTION_DAYS = 180;
|
|
13
|
+
export const AUDIT_MAX_ROWS = 50_000;
|
|
14
|
+
export const AUDIT_DETAILS_MAX_BYTES = 4096;
|
|
15
|
+
const AUDIT_STRING_MAX = 300;
|
|
16
|
+
const PRUNE_EVERY = 500;
|
|
17
|
+
|
|
18
|
+
const ACTION_RE = /^[a-z][a-z0-9.-]{0,99}$/;
|
|
19
|
+
const ACTOR_RE = /^[A-Za-z0-9:._@-]{1,200}$/;
|
|
20
|
+
const PLUGIN_RE = /^[a-z0-9-]{1,100}$/;
|
|
21
|
+
|
|
22
|
+
let insertsSincePrune = 0;
|
|
23
|
+
|
|
24
|
+
function sanitize(value, depth = 0) {
|
|
25
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
26
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
27
|
+
if (typeof value === 'string') {
|
|
28
|
+
return value.length > AUDIT_STRING_MAX ? `${value.slice(0, AUDIT_STRING_MAX)}…` : value;
|
|
29
|
+
}
|
|
30
|
+
if (depth >= 3) return null;
|
|
31
|
+
if (Array.isArray(value)) return value.slice(0, 50).map((v) => sanitize(v, depth + 1));
|
|
32
|
+
if (typeof value === 'object') {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [key, v] of Object.entries(value).slice(0, 50)) {
|
|
35
|
+
if (v !== undefined) out[String(key).slice(0, 64)] = sanitize(v, depth + 1);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Serialise `details`, dropping everything when it would exceed the cap. */
|
|
43
|
+
function encodeDetails(details) {
|
|
44
|
+
const json = JSON.stringify(sanitize(details ?? {}) ?? {});
|
|
45
|
+
if (Buffer.byteLength(json) <= AUDIT_DETAILS_MAX_BYTES) return json;
|
|
46
|
+
return JSON.stringify({ truncated: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Append one entry.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ actor: string, action: string, plugin?: string | null, details?: object }} entry
|
|
53
|
+
* `actor` — `admin`, `agent:<label>`, `plugin:<route>` (a hook's effect),
|
|
54
|
+
* `lamaste` (the panel itself), `auto-approve`; `action` — dotted lower case
|
|
55
|
+
* (`hooks.approved`, `plugin.updated`, `hook.secret-scan.redacted`).
|
|
56
|
+
*/
|
|
57
|
+
export async function recordAudit({ actor, action, plugin = null, details = {} }) {
|
|
58
|
+
if (!ACTOR_RE.test(actor)) throw new Error(`Invalid audit actor "${actor}"`);
|
|
59
|
+
if (!ACTION_RE.test(action)) throw new Error(`Invalid audit action "${action}"`);
|
|
60
|
+
if (plugin !== null && !PLUGIN_RE.test(plugin)) throw new Error('Invalid audit plugin');
|
|
61
|
+
const db = await getStateDb();
|
|
62
|
+
const at = new Date().toISOString();
|
|
63
|
+
const result = db
|
|
64
|
+
.prepare('INSERT INTO audit_events (at, actor, action, plugin, details) VALUES (?, ?, ?, ?, ?)')
|
|
65
|
+
.run(at, actor, action, plugin, encodeDetails(details));
|
|
66
|
+
insertsSincePrune++;
|
|
67
|
+
if (insertsSincePrune >= PRUNE_EVERY) {
|
|
68
|
+
insertsSincePrune = 0;
|
|
69
|
+
pruneAudit(db);
|
|
70
|
+
}
|
|
71
|
+
return { id: Number(result.lastInsertRowid), at };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Append, logging instead of throwing — for paths where the audited action
|
|
76
|
+
* already happened and a full disk must not turn it into an error.
|
|
77
|
+
*/
|
|
78
|
+
export async function recordAuditBestEffort(entry, logger) {
|
|
79
|
+
try {
|
|
80
|
+
return await recordAudit(entry);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger?.error?.({ err: err.message, action: entry.action }, 'Failed to write audit entry');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pruneAudit(db) {
|
|
88
|
+
const cutoff = new Date(Date.now() - AUDIT_RETENTION_DAYS * 86_400_000).toISOString();
|
|
89
|
+
db.prepare('DELETE FROM audit_events WHERE at < ?').run(cutoff);
|
|
90
|
+
db.prepare(
|
|
91
|
+
'DELETE FROM audit_events WHERE id <= (SELECT id FROM audit_events ORDER BY id DESC LIMIT 1 OFFSET ?)',
|
|
92
|
+
).run(AUDIT_MAX_ROWS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rowToEntry(row) {
|
|
96
|
+
let details;
|
|
97
|
+
try {
|
|
98
|
+
details = JSON.parse(row.details);
|
|
99
|
+
} catch {
|
|
100
|
+
details = {};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
id: row.id,
|
|
104
|
+
at: row.at,
|
|
105
|
+
actor: row.actor,
|
|
106
|
+
action: row.action,
|
|
107
|
+
plugin: row.plugin,
|
|
108
|
+
details,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Newest first. `before` pages backwards by id; `action` matches exactly or,
|
|
114
|
+
* ending in `.`, as a prefix (`hooks.`).
|
|
115
|
+
*/
|
|
116
|
+
export async function listAudit({ plugin, action, before, limit = 50 } = {}) {
|
|
117
|
+
const db = await getStateDb();
|
|
118
|
+
const where = [];
|
|
119
|
+
const args = [];
|
|
120
|
+
if (plugin) {
|
|
121
|
+
where.push('plugin = ?');
|
|
122
|
+
args.push(plugin);
|
|
123
|
+
}
|
|
124
|
+
if (action) {
|
|
125
|
+
if (action.endsWith('.')) {
|
|
126
|
+
where.push('substr(action, 1, ?) = ?');
|
|
127
|
+
args.push(action.length, action);
|
|
128
|
+
} else {
|
|
129
|
+
where.push('action = ?');
|
|
130
|
+
args.push(action);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (before) {
|
|
134
|
+
where.push('id < ?');
|
|
135
|
+
args.push(before);
|
|
136
|
+
}
|
|
137
|
+
const sql = `SELECT id, at, actor, action, plugin, details FROM audit_events${
|
|
138
|
+
where.length ? ` WHERE ${where.join(' AND ')}` : ''
|
|
139
|
+
} ORDER BY id DESC LIMIT ?`;
|
|
140
|
+
args.push(Math.min(Math.max(1, limit), 200));
|
|
141
|
+
return db
|
|
142
|
+
.prepare(sql)
|
|
143
|
+
.all(...args)
|
|
144
|
+
.map(rowToEntry);
|
|
145
|
+
}
|