@lamalibre/create-lamaste 4.3.1 → 4.4.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/src/lib/service-config.js +6 -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/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +38 -1
- 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 +6 -6
- 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 +11 -11
- 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/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +7 -7
- 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 +393 -33
- package/vendor/docs/04-api-reference/app-host.md +87 -38
- package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
- package/vendor/docs/04-api-reference/certificates.md +22 -19
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -135
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +2 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +89 -84
- 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 +8 -8
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +2 -2
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +4 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/plugin-settings.js +541 -0
- package/vendor/server/src/commands/plugins.js +94 -27
- package/vendor/server/src/panel-api.js +36 -4
- package/vendor/server/src/plugin-approval.js +78 -0
- package/vendor/server/src/plugin-settings.js +699 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
- package/vendor/server-ui/dist/assets/index-CESyyK8V.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 +9 -0
- package/vendor/serverd/src/lib/agent-presence.js +106 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
- package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
- package/vendor/serverd/src/lib/mtls.js +44 -4
- package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
- package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
- package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
- package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
- package/vendor/serverd/src/lib/plugins.js +73 -22
- package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
- package/vendor/serverd/src/routes/management/plugins.js +166 -19
- package/vendor/serverd/src/routes/management/tunnels.js +21 -1
- package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.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-CESyyK8V.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-25wo_8av.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.4.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.4.0",
|
|
33
33
|
"bcryptjs": "^3.0.3",
|
|
34
34
|
"execa": "^9.6.1",
|
|
35
35
|
"fastify": "^5.8.4",
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
startHostedPluginHealthMonitor,
|
|
38
38
|
stopHostedPluginHealthMonitor,
|
|
39
39
|
} from './lib/plugin-hosting.js';
|
|
40
|
+
import { refreshHostedPluginSettings } from './lib/plugin-settings.js';
|
|
40
41
|
import { syncOidcClients } from './lib/plugin-oidc.js';
|
|
41
42
|
import { ensureRelaySigningKey } from './lib/relay-signing.js';
|
|
42
43
|
import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
|
|
@@ -228,6 +229,14 @@ async function start() {
|
|
|
228
229
|
|
|
229
230
|
// --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
|
|
230
231
|
startHostedPluginHealthMonitor(server.log);
|
|
232
|
+
// The monitor resumes from the persisted health, so a plugin that stayed
|
|
233
|
+
// up across the restart sees no transition that would re-read its
|
|
234
|
+
// settings: read them once now — one plugin after another, best effort —
|
|
235
|
+
// so a change made while the panel was down is seen (and, when Lamaste
|
|
236
|
+
// did not make it, reported).
|
|
237
|
+
refreshHostedPluginSettings(server.log).catch((err) => {
|
|
238
|
+
server.log.warn({ err: err.message }, 'Startup refresh of plugin settings failed');
|
|
239
|
+
});
|
|
231
240
|
|
|
232
241
|
// --- Periodic ticket instance liveness check (every 60s) ---
|
|
233
242
|
const livenessInterval = setInterval(() => {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When each machine agent last contacted the relay — the hosting-agent
|
|
3
|
+
* state the panel shows next to a hosted plugin ("hosting agent offline
|
|
4
|
+
* since … — last sync …").
|
|
5
|
+
*
|
|
6
|
+
* An agent's sync timer calls `GET /api/tunnels/agent-config` every 30 s;
|
|
7
|
+
* that call records the agent here. The time is kept in memory and written
|
|
8
|
+
* to `agent_presence` at most once per {@link PRESENCE_PERSIST_INTERVAL_MS}
|
|
9
|
+
* per agent, so a restart of the panel loses at most that much — and the
|
|
10
|
+
* `agents` table, which the registry rewrites whole, is never touched.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getStateDb } from './state-db.js';
|
|
14
|
+
|
|
15
|
+
/** An agent's contact is written to state.db at most this often. */
|
|
16
|
+
export const PRESENCE_PERSIST_INTERVAL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** label → { seenAt, persistedAt } (epoch milliseconds); filled from the table once. */
|
|
19
|
+
const presence = new Map();
|
|
20
|
+
let loaded = false;
|
|
21
|
+
let clock = () => Date.now();
|
|
22
|
+
|
|
23
|
+
/** Replace the clock (tests); null restores `Date.now`. */
|
|
24
|
+
export function setPresenceClockForTests(fn) {
|
|
25
|
+
clock = fn ?? (() => Date.now());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Forget what memory holds, as a restart of the panel would (tests). */
|
|
29
|
+
export function forgetPresenceMemoryForTests() {
|
|
30
|
+
presence.clear();
|
|
31
|
+
loaded = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadOnce(db) {
|
|
35
|
+
if (loaded) return;
|
|
36
|
+
for (const row of db.prepare('SELECT label, last_seen_at FROM agent_presence').all()) {
|
|
37
|
+
const at = Date.parse(row.last_seen_at);
|
|
38
|
+
if (Number.isFinite(at) && !presence.has(row.label)) {
|
|
39
|
+
presence.set(row.label, { seenAt: at, persistedAt: at });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
loaded = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Agent `label` contacted the relay now. Returns whether the contact was
|
|
47
|
+
* written to state.db (false while the last write is under a minute old).
|
|
48
|
+
*
|
|
49
|
+
* @param {string} label
|
|
50
|
+
* @returns {Promise<{ persisted: boolean }>}
|
|
51
|
+
*/
|
|
52
|
+
export async function recordAgentContact(label) {
|
|
53
|
+
const db = await getStateDb();
|
|
54
|
+
loadOnce(db);
|
|
55
|
+
const now = clock();
|
|
56
|
+
const entry = presence.get(label);
|
|
57
|
+
if (
|
|
58
|
+
entry &&
|
|
59
|
+
entry.persistedAt !== null &&
|
|
60
|
+
now >= entry.persistedAt &&
|
|
61
|
+
now - entry.persistedAt < PRESENCE_PERSIST_INTERVAL_MS
|
|
62
|
+
) {
|
|
63
|
+
entry.seenAt = Math.max(entry.seenAt, now);
|
|
64
|
+
return { persisted: false };
|
|
65
|
+
}
|
|
66
|
+
db.prepare(
|
|
67
|
+
`INSERT INTO agent_presence (label, last_seen_at) VALUES (?, ?)
|
|
68
|
+
ON CONFLICT(label) DO UPDATE SET last_seen_at = excluded.last_seen_at`,
|
|
69
|
+
).run(label, new Date(now).toISOString());
|
|
70
|
+
presence.set(label, { seenAt: now, persistedAt: now });
|
|
71
|
+
return { persisted: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* When each agent last contacted the relay (ISO 8601), by label. Agents
|
|
76
|
+
* that never did are absent.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<Map<string, string>>}
|
|
79
|
+
*/
|
|
80
|
+
export async function agentPresenceMap() {
|
|
81
|
+
const db = await getStateDb();
|
|
82
|
+
loadOnce(db);
|
|
83
|
+
const out = new Map();
|
|
84
|
+
for (const [label, entry] of presence) out.set(label, new Date(entry.seenAt).toISOString());
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* When agent `label` last contacted the relay (ISO 8601), or null.
|
|
90
|
+
* @param {string} label
|
|
91
|
+
*/
|
|
92
|
+
export async function agentLastSeenAt(label) {
|
|
93
|
+
return (await agentPresenceMap()).get(label) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Drop what is known of agent `label` — its certificate was revoked, and a
|
|
98
|
+
* later enrollment reusing the label must not inherit its last contact.
|
|
99
|
+
* @param {string} label
|
|
100
|
+
*/
|
|
101
|
+
export async function forgetAgentPresence(label) {
|
|
102
|
+
const db = await getStateDb();
|
|
103
|
+
loadOnce(db);
|
|
104
|
+
db.prepare('DELETE FROM agent_presence WHERE label = ?').run(label);
|
|
105
|
+
presence.delete(label);
|
|
106
|
+
}
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
* plugin that owns it; a teardown acts only on the plugin's own.
|
|
41
41
|
*
|
|
42
42
|
* Hook outputs are passed forward in `outputs` (`relay:install:pre-tunnel`, …).
|
|
43
|
+
* Relay stages see the plugin's effective non-secret settings (the relay's
|
|
44
|
+
* cache, `plugin-settings.js`); agent stages see none — the agent half holds
|
|
45
|
+
* them. The cache goes once the hosting it describes is removed.
|
|
43
46
|
*/
|
|
44
47
|
|
|
45
48
|
import {
|
|
@@ -79,6 +82,7 @@ import { installedAgentHalf, installedRelayHooks } from './plugin-hooks.js';
|
|
|
79
82
|
import { forgetRetainedData, recordRetainedData } from './retained-data.js';
|
|
80
83
|
import { runRelayHook } from './relay-hooks.js';
|
|
81
84
|
import { recordAuditBestEffort } from './audit.js';
|
|
85
|
+
import { deleteSettingsCache, relayHookSettings } from './plugin-settings.js';
|
|
82
86
|
import {
|
|
83
87
|
tunnelNginxDeps,
|
|
84
88
|
tunnelCertbotDeps,
|
|
@@ -97,15 +101,19 @@ const TUNNEL_READY_POLL_MS = 3_000;
|
|
|
97
101
|
// Stages
|
|
98
102
|
// ---------------------------------------------------------------------------
|
|
99
103
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The pipeline-wide context: who, what, and the outputs so far. `settings`
|
|
106
|
+
* is the plugin's effective non-secret settings for a relay stage, `{}` for
|
|
107
|
+
* an agent stage.
|
|
108
|
+
*/
|
|
109
|
+
function lifecycleInput(ctx, phase, settings = {}) {
|
|
102
110
|
return {
|
|
103
111
|
plugin: ctx.plugin.name,
|
|
104
112
|
version: ctx.version ?? ctx.plugin.version,
|
|
105
113
|
...(ctx.previousVersion ? { previousVersion: ctx.previousVersion } : {}),
|
|
106
114
|
phase,
|
|
107
115
|
agent: { label: ctx.agentLabel },
|
|
108
|
-
settings
|
|
116
|
+
settings,
|
|
109
117
|
outputs: { ...ctx.outputs },
|
|
110
118
|
...(ctx.port ? { port: ctx.port } : {}),
|
|
111
119
|
};
|
|
@@ -132,7 +140,7 @@ async function relayStage(op, ctx, stage, phase) {
|
|
|
132
140
|
plugin: ctx.plugin.name,
|
|
133
141
|
stage,
|
|
134
142
|
call: { kind: 'app' },
|
|
135
|
-
input: lifecycleInput(ctx, phase),
|
|
143
|
+
input: lifecycleInput(ctx, phase, await relayHookSettings(ctx.plugin)),
|
|
136
144
|
timeoutMs: decl.timeoutMs,
|
|
137
145
|
effects: ctx.relay.set.effects,
|
|
138
146
|
logger: ctx.logger,
|
|
@@ -821,6 +829,14 @@ export async function runUninstallPipeline(op, ctx, { force = false } = {}) {
|
|
|
821
829
|
: null;
|
|
822
830
|
await runTeardown(op, ctx, { phase: 'uninstall', force });
|
|
823
831
|
await cancelAgentJobs(ctx.route).catch(() => undefined);
|
|
832
|
+
// The relay's cache of the settings describes the hosting's agent half:
|
|
833
|
+
// with the hosting gone it describes nothing (a later hosting reads them
|
|
834
|
+
// afresh — from data the agent kept, or none).
|
|
835
|
+
if (!(await getPluginHosting(STATE_DIR, ctx.route))) {
|
|
836
|
+
await deleteSettingsCache(ctx.plugin.name).catch((err) =>
|
|
837
|
+
op.warn(`Could not drop the cached settings: ${err.message}`),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
824
840
|
await recordAuditBestEffort(
|
|
825
841
|
{
|
|
826
842
|
actor: op.actor ?? 'admin',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- Plugin settings and the presence of hosting agents (4.3, slice 4).
|
|
2
|
+
|
|
3
|
+
-- The manifest's `settings` block (JSON; NULL when the plugin declares
|
|
4
|
+
-- none): the schema the panel checks an administrator's change against
|
|
5
|
+
-- before sending it to the agent half, and the keys it keeps in the cache
|
|
6
|
+
-- below. Settings are not part of the approval digest.
|
|
7
|
+
ALTER TABLE plugins ADD COLUMN settings TEXT;
|
|
8
|
+
|
|
9
|
+
-- The relay's cache of each plugin's settings. The agent half is the system
|
|
10
|
+
-- of record (settings.json and secrets.json in its data directory); this row
|
|
11
|
+
-- is refreshed from its `GET /admin/settings` after hosting, after an
|
|
12
|
+
-- update, when the health monitor sees the plugin come up, and from its
|
|
13
|
+
-- answer to every change — so the panel can show the settings, and whether
|
|
14
|
+
-- the plugin still needs configuration, while the agent is offline.
|
|
15
|
+
-- There is no column for a secret's value: a secret exists on the relay only
|
|
16
|
+
-- in the memory of the request that carries it to the agent half.
|
|
17
|
+
-- schema_version the agent half's settings schema version
|
|
18
|
+
-- revision the agent half's revision (+1 per change)
|
|
19
|
+
-- values_json JSON {key: value} — the non-secret values the agent half
|
|
20
|
+
-- stored (no defaults), for keys the installed schema knows
|
|
21
|
+
-- secrets_set_json JSON {key: setAt} — which secrets are set, and when
|
|
22
|
+
-- configured 1 when no required field lacks a value
|
|
23
|
+
-- missing_json JSON [key] — the required fields without a value
|
|
24
|
+
-- synced_at when the relay last read the agent half's state
|
|
25
|
+
-- lamaste_revision the agent half's revision as Lamaste last wrote it (its
|
|
26
|
+
-- answer to a change) or adopted it; NULL until the first
|
|
27
|
+
-- read. A refresh that finds another revision found a
|
|
28
|
+
-- change made outside Lamaste: audited and notified (key
|
|
29
|
+
-- names only), then adopted.
|
|
30
|
+
-- unconfirmed_change_json
|
|
31
|
+
-- JSON {revision, actor, certSerial?, changed, cleared,
|
|
32
|
+
-- secretsSet, secretsCleared} — the key names of a change
|
|
33
|
+
-- whose answer never authenticated (a timeout, say), which
|
|
34
|
+
-- the agent half may have stored. A refresh that finds
|
|
35
|
+
-- exactly that revision, changed in those keys only,
|
|
36
|
+
-- records the change as Lamaste's.
|
|
37
|
+
-- When the installed settings schema changes (an update or its revert), the
|
|
38
|
+
-- values and secrets whose field changed type are pruned in the transaction
|
|
39
|
+
-- that writes the plugin's row.
|
|
40
|
+
-- Removed when the plugin's hosting is removed and when it is uninstalled.
|
|
41
|
+
CREATE TABLE plugin_settings (
|
|
42
|
+
plugin TEXT PRIMARY KEY,
|
|
43
|
+
schema_version INTEGER NOT NULL,
|
|
44
|
+
revision INTEGER NOT NULL,
|
|
45
|
+
values_json TEXT NOT NULL,
|
|
46
|
+
secrets_set_json TEXT NOT NULL,
|
|
47
|
+
configured INTEGER NOT NULL,
|
|
48
|
+
missing_json TEXT NOT NULL,
|
|
49
|
+
synced_at TEXT NOT NULL,
|
|
50
|
+
lamaste_revision INTEGER,
|
|
51
|
+
unconfirmed_change_json TEXT
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- When each machine agent last contacted the relay: its sync's
|
|
55
|
+
-- `GET /api/tunnels/agent-config`, every 30 s. Kept in memory and written
|
|
56
|
+
-- here at most once a minute per agent — never through the `agents` table,
|
|
57
|
+
-- which the registry rewrites whole. Tells how long a hosting agent has been
|
|
58
|
+
-- gone. Removed when the agent's certificate is revoked.
|
|
59
|
+
CREATE TABLE agent_presence (
|
|
60
|
+
label TEXT PRIMARY KEY,
|
|
61
|
+
last_seen_at TEXT NOT NULL
|
|
62
|
+
);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
BASE_CAPABILITIES as CORE_BASE_CAPABILITIES,
|
|
18
18
|
HostFactsSchema,
|
|
19
19
|
PLUGIN_AGENT_CN_PREFIX as CORE_PLUGIN_AGENT_CN_PREFIX,
|
|
20
|
+
hostFactsHash,
|
|
20
21
|
} from '@lamalibre/lamaste';
|
|
21
22
|
import { addToRevocationList } from './revocation.js';
|
|
22
23
|
import { releaseAgentTunnels, stripPluginCapabilities } from '@lamalibre/lamaste/server';
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
rotateChiselCredential as rotateChiselCredentialLib,
|
|
28
29
|
} from './chisel-users.js';
|
|
29
30
|
import { getStateDb } from './state-db.js';
|
|
31
|
+
import { agentPresenceMap, forgetAgentPresence } from './agent-presence.js';
|
|
30
32
|
import { randomCertSerial, signAgentCsr } from './pki-sign.js';
|
|
31
33
|
|
|
32
34
|
const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
|
|
@@ -577,14 +579,17 @@ export async function generateAgentCert(label, logger, capabilities, allowedSite
|
|
|
577
579
|
}
|
|
578
580
|
|
|
579
581
|
/**
|
|
580
|
-
* List all agent certificates with expiry status
|
|
581
|
-
* agent last reported
|
|
582
|
-
*
|
|
582
|
+
* List all agent certificates with expiry status, the host facts each
|
|
583
|
+
* agent last reported and when it last contacted the relay (`lastSeenAt`:
|
|
584
|
+
* its sync's agent-config call; null when it never did since it enrolled).
|
|
585
|
+
* `status` sums a certificate up for the panel: `revoked`, `expired`,
|
|
586
|
+
* `expiring-soon` (within 30 days) or `active`.
|
|
583
587
|
*
|
|
584
|
-
* @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean, status: 'active' | 'expiring-soon' | 'expired' | 'revoked', hostFacts: object | null, hostFactsAt: string | null }>>}
|
|
588
|
+
* @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean, status: 'active' | 'expiring-soon' | 'expired' | 'revoked', hostFacts: object | null, hostFactsAt: string | null, lastSeenAt: string | null }>>}
|
|
585
589
|
*/
|
|
586
590
|
export async function listAgentCerts() {
|
|
587
591
|
const registry = await loadAgentRegistry();
|
|
592
|
+
const lastSeen = await agentPresenceMap();
|
|
588
593
|
const now = Date.now();
|
|
589
594
|
|
|
590
595
|
return registry.agents.map((agent) => {
|
|
@@ -612,6 +617,7 @@ export async function listAgentCerts() {
|
|
|
612
617
|
// What the agent last reported about its host (null until it has).
|
|
613
618
|
hostFacts: agent.hostFacts ?? null,
|
|
614
619
|
hostFactsAt: agent.hostFactsAt ?? null,
|
|
620
|
+
lastSeenAt: agent.revoked ? null : (lastSeen.get(agent.label) ?? null),
|
|
615
621
|
};
|
|
616
622
|
if (isPluginAgent) {
|
|
617
623
|
entry.certType = 'plugin-agent';
|
|
@@ -738,6 +744,34 @@ export function setAgentHostFacts(label, hostFacts) {
|
|
|
738
744
|
});
|
|
739
745
|
}
|
|
740
746
|
|
|
747
|
+
/**
|
|
748
|
+
* The host facts agent `label` last reported, as stored on its enrolled,
|
|
749
|
+
* unrevoked record — null when it has none: it never reported, its record
|
|
750
|
+
* was replaced since (enrolled again on the label, a restored backup), or
|
|
751
|
+
* the stored facts no longer parse.
|
|
752
|
+
*
|
|
753
|
+
* @param {string} label
|
|
754
|
+
* @returns {Promise<import('@lamalibre/lamaste').HostFacts | null>}
|
|
755
|
+
*/
|
|
756
|
+
export async function getAgentHostFacts(label) {
|
|
757
|
+
const registry = await loadAgentRegistry();
|
|
758
|
+
return registry.agents.find((a) => a.label === label && !a.revoked)?.hostFacts ?? null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The hash of the host facts stored for agent `label` — core's
|
|
763
|
+
* `hostFactsHash`, exactly what the agent computes over its own — or null
|
|
764
|
+
* when it has none ({@link getAgentHostFacts}). The agent's sync gets it in
|
|
765
|
+
* agent-config and reports its facts again when it is not theirs.
|
|
766
|
+
*
|
|
767
|
+
* @param {string} label
|
|
768
|
+
* @returns {Promise<string | null>}
|
|
769
|
+
*/
|
|
770
|
+
export async function getAgentHostFactsHash(label) {
|
|
771
|
+
const facts = await getAgentHostFacts(label);
|
|
772
|
+
return facts === null ? null : hostFactsHash(facts);
|
|
773
|
+
}
|
|
774
|
+
|
|
741
775
|
/**
|
|
742
776
|
* Revoke an agent certificate by label.
|
|
743
777
|
*
|
|
@@ -793,6 +827,12 @@ export async function revokeAgentCert(label, logger) {
|
|
|
793
827
|
// 4. Save registry atomically (includes both the agent and cascade-revoked plugin-agents)
|
|
794
828
|
await saveAgentRegistry(registry);
|
|
795
829
|
|
|
830
|
+
// When the agent last synced: a later enrollment reusing the label
|
|
831
|
+
// starts without it.
|
|
832
|
+
await forgetAgentPresence(label).catch((err) => {
|
|
833
|
+
logger.warn({ err: err.message, label }, 'Failed to forget the agent presence');
|
|
834
|
+
});
|
|
835
|
+
|
|
796
836
|
// 5. Remove agent's key/cert/p12 files (lamaste owns the agents directory)
|
|
797
837
|
await execa('rm', ['-rf', `${AGENTS_DIR}/${label}/`]).catch((err) => {
|
|
798
838
|
logger.warn({ err, label }, 'Failed to remove agent certificate files');
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* tarball integrity, service and system packages. Approval is needed when
|
|
11
11
|
* either exists. Lamaste runs relay hooks, hosts the plugin and installs its
|
|
12
12
|
* agent half only while the installed version's digest is the approved
|
|
13
|
-
* one.
|
|
13
|
+
* one. Settings are outside the digest, but an update that turns a secret
|
|
14
|
+
* setting into a plain one waits for an administrator's approval all the
|
|
15
|
+
* same (`plugin-lifecycle.js`): the same pending update, approved here.
|
|
14
16
|
*
|
|
15
17
|
* The runner is about the relay part alone: it holds a root-owned copy of
|
|
16
18
|
* the approved module that lamaste-priv installed after checking its
|
|
@@ -281,22 +283,67 @@ export async function hookStatus(plugin) {
|
|
|
281
283
|
};
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
/** Why an update waits: its approval digest is not the approved one. */
|
|
287
|
+
export const DIGEST_UNAPPROVED = 'digest.unapproved';
|
|
288
|
+
/** Why an update waits: it turns secret settings into plain ones. */
|
|
289
|
+
export const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The reason a staged version waits because what it brings — its relay
|
|
293
|
+
* hooks (`relay`), the agent half Lamaste installs (`agentHalf`); either
|
|
294
|
+
* truthy when it brings one — is not what was approved.
|
|
295
|
+
*
|
|
296
|
+
* @returns {{ code: string, reason: string }}
|
|
297
|
+
*/
|
|
298
|
+
export function digestUnapprovedReason({ relay, agentHalf }) {
|
|
299
|
+
const what =
|
|
300
|
+
relay && agentHalf
|
|
301
|
+
? 'its relay hooks and agent half are'
|
|
302
|
+
: agentHalf
|
|
303
|
+
? 'its agent half is'
|
|
304
|
+
: 'its relay hooks are';
|
|
305
|
+
return { code: DIGEST_UNAPPROVED, reason: `${what} not what was approved` };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Why a stored pending update waits for an administrator. A record staged
|
|
310
|
+
* before the reasons were stored waited for one reason only: its digest.
|
|
311
|
+
*
|
|
312
|
+
* @returns {Array<{ code: string, reason: string }>}
|
|
313
|
+
*/
|
|
314
|
+
function pendingApprovalRequired(pending) {
|
|
315
|
+
return (
|
|
316
|
+
pending.approvalRequired ?? [
|
|
317
|
+
digestUnapprovedReason({ relay: pending.preview, agentHalf: pending.agentHalf }),
|
|
318
|
+
]
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
284
322
|
/**
|
|
285
323
|
* A staged update waiting for approval as clients see it — in the hook
|
|
286
324
|
* status and in the update response alike: its version, the digest to
|
|
287
|
-
* approve it with
|
|
288
|
-
* Lamaste installs
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
325
|
+
* approve it with (null when it brings neither relay hooks nor an agent
|
|
326
|
+
* half Lamaste installs: it is approved by its version), what it brings
|
|
327
|
+
* (its relay hook set and the agent half Lamaste installs, each null when
|
|
328
|
+
* it brings none), how it changes the settings schema (`settings`:
|
|
329
|
+
* `{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key,
|
|
330
|
+
* from, to }], newRequired: [key], secretToPlain: [key] }`, null when
|
|
331
|
+
* neither version declares settings — not part of the digest), why it
|
|
332
|
+
* waits for an administrator (`approvalRequired`: `[{ code, reason }]`,
|
|
333
|
+
* `digest.unapproved` and `settings.secret-to-plain`; never empty), and —
|
|
334
|
+
* when the plugin has auto-approve on — why auto-approve did not cover it
|
|
335
|
+
* (`autoApproveWithheld`, sentences; null otherwise). The stored record
|
|
336
|
+
* names the relay hook set's preview `preview`.
|
|
292
337
|
*/
|
|
293
338
|
export function pendingUpdateView(pending) {
|
|
294
339
|
return {
|
|
295
340
|
version: pending.version,
|
|
296
|
-
digest: pending.digest,
|
|
341
|
+
digest: pending.digest ?? null,
|
|
297
342
|
stagedAt: pending.stagedAt,
|
|
298
343
|
relay: pending.preview ?? null,
|
|
299
344
|
agentHalf: pending.agentHalf ?? null,
|
|
345
|
+
settings: pending.settings ?? null,
|
|
346
|
+
approvalRequired: pendingApprovalRequired(pending),
|
|
300
347
|
autoApproveWithheld: pending.autoApproveWithheld ?? null,
|
|
301
348
|
};
|
|
302
349
|
}
|
|
@@ -416,28 +463,71 @@ export async function restartRelayHookRunner(plugin, logger) {
|
|
|
416
463
|
/**
|
|
417
464
|
* An administrator approves what a plugin version brings — its relay hook
|
|
418
465
|
* set and the agent half Lamaste installs — with the digest they were
|
|
419
|
-
* shown
|
|
420
|
-
*
|
|
421
|
-
*
|
|
466
|
+
* shown: the installed version's, or a pending update's. A pending update
|
|
467
|
+
* is also approved by naming its `version`, with its digest when it has
|
|
468
|
+
* one — the only way to approve one that brings neither relay hooks nor an
|
|
469
|
+
* agent half (its digest is null), and what binds the approval of a
|
|
470
|
+
* settings change to the release that was shown.
|
|
422
471
|
*
|
|
423
|
-
*
|
|
472
|
+
* The installed version's approval is recorded here, in the hook state and
|
|
473
|
+
* the audit log. A pending update's is checked against the update that is
|
|
474
|
+
* waiting and handed back for the caller to apply (`updatePlugin` with
|
|
475
|
+
* `approvedUpdate`): its version, its digest and the secret settings it
|
|
476
|
+
* turns plain as they were shown. It approves everything the update waits
|
|
477
|
+
* for (`approvalRequired`), its digest changed or not, and is audited when
|
|
478
|
+
* the update starts under it.
|
|
479
|
+
*
|
|
480
|
+
* @param {object} plugin the installed registry entry
|
|
481
|
+
* @param {string | null} digest
|
|
482
|
+
* @param {{ actor: string, certSerial: string | null, logger?: object, version?: string | null }} options
|
|
483
|
+
* @returns {Promise<{ target: 'installed', digest: string } | { target: 'update', digest: string | null, version: string, secretToPlain: string[] }>}
|
|
424
484
|
*/
|
|
425
|
-
export async function approveHookSet(
|
|
485
|
+
export async function approveHookSet(
|
|
486
|
+
plugin,
|
|
487
|
+
digest,
|
|
488
|
+
{ actor, certSerial, logger, version = null },
|
|
489
|
+
) {
|
|
426
490
|
const approval = installedApproval(plugin);
|
|
427
491
|
const { relay } = approval;
|
|
428
492
|
const state = await readHookState(plugin.name);
|
|
429
493
|
const pending = state?.pendingUpdate ?? null;
|
|
430
494
|
const now = new Date().toISOString();
|
|
431
495
|
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
496
|
+
if (version !== null) {
|
|
497
|
+
if (!pending) {
|
|
498
|
+
throw httpError(`Plugin "${plugin.name}" has no update waiting for approval`, 409);
|
|
499
|
+
}
|
|
500
|
+
if (pending.version !== version) {
|
|
501
|
+
throw httpError(
|
|
502
|
+
`The update waiting for approval is version ${pending.version}, not ${version} — review it again`,
|
|
503
|
+
409,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const pendingDigest = pending.digest ?? null;
|
|
507
|
+
if (digest === null && pendingDigest !== null) {
|
|
508
|
+
throw httpError(
|
|
509
|
+
`Version ${version} brings relay hooks or an agent half — approve it with the approval digest it shows`,
|
|
510
|
+
400,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (digest !== pendingDigest) {
|
|
514
|
+
throw httpError(
|
|
515
|
+
`What version ${version} brings changed since it was shown — review it again`,
|
|
516
|
+
409,
|
|
517
|
+
{
|
|
518
|
+
currentDigest: pendingDigest,
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return pendingUpdateApproval(pending);
|
|
440
523
|
}
|
|
524
|
+
if (digest === null) {
|
|
525
|
+
throw httpError(
|
|
526
|
+
"Approving names the approval digest that was shown, or a pending update's version",
|
|
527
|
+
400,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (pending && pending.digest === digest) return pendingUpdateApproval(pending);
|
|
441
531
|
if (!approval.required) {
|
|
442
532
|
throw httpError(
|
|
443
533
|
`Plugin "${plugin.name}" has no relay hooks and no agent half Lamaste installs — nothing to approve`,
|
|
@@ -466,6 +556,16 @@ export async function approveHookSet(plugin, digest, { actor, certSerial, logger
|
|
|
466
556
|
return { target: 'installed', digest };
|
|
467
557
|
}
|
|
468
558
|
|
|
559
|
+
/** What approving the pending update `pending` hands its caller to apply: the update as it was shown. */
|
|
560
|
+
function pendingUpdateApproval(pending) {
|
|
561
|
+
return {
|
|
562
|
+
target: 'update',
|
|
563
|
+
digest: pending.digest ?? null,
|
|
564
|
+
version: pending.version,
|
|
565
|
+
secretToPlain: pending.settings?.secretToPlain ?? [],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
469
569
|
/**
|
|
470
570
|
* Record an approval of an applied update (by an admin or auto-approve):
|
|
471
571
|
* `digest` is the version's approval digest (`installedApproval`).
|