@sagentix-inc/evidence-collector 0.2.0 → 0.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/README.md +166 -5
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/apps/sos-app/src/data/checks/configmgr.json +45 -0
- package/apps/sos-app/src/data/checks/endpoint_central.json +45 -0
- package/apps/sos-app/src/data/checks/google_endpoints.json +39 -0
- package/apps/sos-app/src/data/checks/ivanti_mdm.json +51 -0
- package/apps/sos-app/src/data/checks/ninjaone.json +39 -0
- package/apps/sos-app/src/data/checks/workspace_one.json +45 -0
- package/collector/package.json +1 -1
- package/collector/src/agent.js +312 -0
- package/collector/src/channel.js +220 -0
- package/collector/src/cli.js +160 -89
- package/collector/src/release_keys.js +9 -0
- package/collector/src/report.js +300 -0
- package/collector/src/sources/azure/checks.js +4 -2
- package/collector/src/sources/configmgr.js +286 -0
- package/collector/src/sources/device_sources.js +11 -1
- package/collector/src/sources/endpoint_central.js +345 -0
- package/collector/src/sources/gcp/api.js +38 -2
- package/collector/src/sources/gcp.js +6 -4
- package/collector/src/sources/google_endpoints.js +171 -0
- package/collector/src/sources/ivanti_mdm.js +276 -0
- package/collector/src/sources/ninjaone.js +271 -0
- package/collector/src/sources/oauth.js +4 -2
- package/collector/src/sources/workspace_one.js +226 -0
- package/package.json +6 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// `collector agent --config <file>`: resident mode (apps/sos-app/docs/COLLECTOR_CHANNEL.md, section 5). Keeps one
|
|
2
|
+
// outbound WebSocket open to sos-app's /api/collect/connect so sos-app can ask for a run now instead of waiting for
|
|
3
|
+
// the schedule. The channel carries four words and nothing else: sos-app may say run, checkin or ping; the agent says
|
|
4
|
+
// hello, status and pong. A run is the same `run --config <file>` the schedule starts, as a child process, never two
|
|
5
|
+
// at once. No message can carry a command, a file or a configuration, so the collector stays read-only whatever the
|
|
6
|
+
// server says; anything else received is logged and ignored.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, extname, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { CONNECT_PATH, EXIT_UPDATE, MODE_ENV, checkin, collectorInfo, detectRunner } from "./channel.js";
|
|
13
|
+
import { loadConfig } from "./config.js";
|
|
14
|
+
import { signBody } from "./keys.js";
|
|
15
|
+
export const PING_MS = 50_000;
|
|
16
|
+
export const CHECKIN_MS = 6 * 3600_000;
|
|
17
|
+
export const BACKOFF_MIN_MS = 1000;
|
|
18
|
+
export const BACKOFF_MAX_MS = 5 * 60_000;
|
|
19
|
+
// cli.ts beside this file in the repository, cli.js in the npm package (tsc output): the same extension as this one.
|
|
20
|
+
const CLI_PATH = fileURLToPath(new URL(`./cli${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
|
|
21
|
+
/** The next reconnect delay: 1 s doubling to 5 min. */
|
|
22
|
+
export const nextBackoff = (ms) => Math.min(BACKOFF_MAX_MS, Math.max(BACKOFF_MIN_MS, ms * 2));
|
|
23
|
+
/** wss://<origin>/api/collect/connect?key=&ts=&sig= with sig the base64url Ed25519 signature over "sagentix-connect/1\n<key id>\n<ts>". */
|
|
24
|
+
export function connectUrl(endpoint, keyId, pem, nowMs) {
|
|
25
|
+
const u = new URL(CONNECT_PATH, new URL(endpoint).origin);
|
|
26
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
27
|
+
const ts = String(Math.floor(nowMs));
|
|
28
|
+
const sig = Buffer.from(signBody(pem, new TextEncoder().encode(`sagentix-connect/1\n${keyId}\n${ts}`)), "base64").toString("base64url");
|
|
29
|
+
u.search = new URLSearchParams({ key: keyId, ts, sig }).toString();
|
|
30
|
+
return u.toString();
|
|
31
|
+
}
|
|
32
|
+
/** The agent's side of the protocol, without a socket: what to send for each message received. */
|
|
33
|
+
export class AgentSession {
|
|
34
|
+
h;
|
|
35
|
+
busy;
|
|
36
|
+
/** A run's final status that found the socket closed; sent after the next hello. */
|
|
37
|
+
unsent;
|
|
38
|
+
constructor(hooks) { this.h = hooks; }
|
|
39
|
+
get running() { return this.busy?.id; }
|
|
40
|
+
onOpen() {
|
|
41
|
+
this.h.send(JSON.stringify({ type: "hello", ...this.h.hello }));
|
|
42
|
+
if (this.unsent && this.h.send(this.unsent))
|
|
43
|
+
this.unsent = undefined;
|
|
44
|
+
}
|
|
45
|
+
onMessage(data) {
|
|
46
|
+
if (typeof data !== "string") {
|
|
47
|
+
this.h.log("agent: ignored a message that is not text");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let m;
|
|
51
|
+
try {
|
|
52
|
+
m = JSON.parse(data);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
this.h.log("agent: ignored a message that is not JSON");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof m !== "object" || m === null) {
|
|
59
|
+
this.h.log("agent: ignored a message that is not an object");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
switch (m.type) {
|
|
63
|
+
case "ping":
|
|
64
|
+
this.h.send(JSON.stringify({ type: "pong" }));
|
|
65
|
+
return;
|
|
66
|
+
case "pong": return; // the answer to our own ping
|
|
67
|
+
case "checkin":
|
|
68
|
+
this.h.checkin().catch((e) => this.h.log(`agent: check-in failed: ${String(e?.message ?? e)}`));
|
|
69
|
+
return;
|
|
70
|
+
case "run":
|
|
71
|
+
this.run(m.id);
|
|
72
|
+
return;
|
|
73
|
+
default: this.h.log(`agent: ignored a message of type ${JSON.stringify(String(m.type ?? "")).slice(0, 60)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
status(id, state, exitCode) {
|
|
77
|
+
const text = JSON.stringify({ type: "status", id, state, exitCode });
|
|
78
|
+
if (!this.h.send(text) && state !== "started")
|
|
79
|
+
this.unsent = text;
|
|
80
|
+
}
|
|
81
|
+
run(id) {
|
|
82
|
+
if (typeof id !== "string" || !id || id.length > 100) {
|
|
83
|
+
this.h.log("agent: ignored a run without a usable id");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (this.busy) {
|
|
87
|
+
this.h.log(`agent: run ${id} refused: run ${this.busy.id} is still going`);
|
|
88
|
+
this.status(id, "failed", null);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = this.h.startRun();
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
this.h.log(`agent: cannot start run ${id}: ${String(e?.message ?? e)}`);
|
|
97
|
+
this.status(id, "failed", null);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.busy = { id, child };
|
|
101
|
+
this.h.log(`agent: run ${id} started`);
|
|
102
|
+
this.status(id, "started", null);
|
|
103
|
+
child.exit.then((code) => {
|
|
104
|
+
this.h.log(`agent: run ${id} ended with exit code ${code}`);
|
|
105
|
+
this.status(id, code === 0 ? "done" : "failed", code);
|
|
106
|
+
}, (e) => {
|
|
107
|
+
this.h.log(`agent: run ${id} failed: ${String(e?.message ?? e)}`);
|
|
108
|
+
this.status(id, "failed", null);
|
|
109
|
+
}).finally(() => { this.busy = undefined; this.h.onIdle?.(); });
|
|
110
|
+
}
|
|
111
|
+
/** Stops a run in progress (the agent is exiting). */
|
|
112
|
+
stop() { this.busy?.child.kill(); }
|
|
113
|
+
}
|
|
114
|
+
/** What a run started from sos-app executes (exported for the tests). */
|
|
115
|
+
export function runCommand(configPath, exists, platform = process.platform) {
|
|
116
|
+
const dir = dirname(resolve(configPath));
|
|
117
|
+
const ps1 = resolve(dir, "sagentix-collector.ps1"), sh = resolve(dir, "sagentix-collector.sh");
|
|
118
|
+
if (platform === "win32" && exists(ps1))
|
|
119
|
+
return ["powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1]];
|
|
120
|
+
if (platform !== "win32" && exists(sh))
|
|
121
|
+
return ["/bin/bash", [sh]];
|
|
122
|
+
return [process.execPath, [...process.execArgv, CLI_PATH, "run", "--config", configPath]];
|
|
123
|
+
}
|
|
124
|
+
export function realAgentIo() {
|
|
125
|
+
return {
|
|
126
|
+
socket: (url) => new WebSocket(url),
|
|
127
|
+
spawnRun(configPath, env) {
|
|
128
|
+
// The generated script beside the config when there is one, so a run started from sos-app does what the daily one
|
|
129
|
+
// does (the update checkout, the application scans, then the collector); else this file's own cli.ts, with the
|
|
130
|
+
// same Node and flags this agent runs with (process.execArgv). Inside the runner image there is no script.
|
|
131
|
+
const [cmd, args] = runCommand(configPath, existsSync);
|
|
132
|
+
const child = spawn(cmd, args, { stdio: "inherit", cwd: dirname(resolve(configPath)), env: { ...env, [MODE_ENV]: "agent" } });
|
|
133
|
+
const exit = new Promise((res, rej) => { child.once("error", rej); child.once("exit", (code) => res(code)); });
|
|
134
|
+
return { exit, kill: () => { child.kill("SIGTERM"); } };
|
|
135
|
+
},
|
|
136
|
+
onStop(stop) { let done = false; const once = () => { if (!done) {
|
|
137
|
+
done = true;
|
|
138
|
+
stop();
|
|
139
|
+
} }; process.once("SIGINT", once); process.once("SIGTERM", once); },
|
|
140
|
+
setTimeout: (f, ms) => setTimeout(f, ms),
|
|
141
|
+
clearTimeout: (t) => clearTimeout(t),
|
|
142
|
+
setInterval: (f, ms) => setInterval(f, ms),
|
|
143
|
+
clearInterval: (t) => clearInterval(t),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const OPEN = 1;
|
|
147
|
+
export async function agent(argv, deps, io = realAgentIo()) {
|
|
148
|
+
let values;
|
|
149
|
+
try {
|
|
150
|
+
({ values } = parseArgs({ args: argv, options: { config: { type: "string" } }, strict: true }));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
deps.err(`${e.message}\nusage: collector agent --config collector.config.json`);
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
if (!values.config) {
|
|
157
|
+
deps.err("usage: collector agent --config collector.config.json");
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
const configPath = resolve(values.config);
|
|
161
|
+
const c = loadConfig(configPath, deps.sources);
|
|
162
|
+
if ("problems" in c) {
|
|
163
|
+
deps.err(`${values.config} is not a valid collector config:\n ${c.problems.join("\n ")}`);
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
const endpoint = c.values.endpoint, keyId = c.values["key-id"];
|
|
167
|
+
let pem;
|
|
168
|
+
if (typeof c.values.key === "string") {
|
|
169
|
+
try {
|
|
170
|
+
pem = readFileSync(resolve(c.values.key), "utf8");
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
deps.err(`cannot read keyFile ${c.values.key}: ${e.code ?? String(e)}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
pem = deps.env.SAGENTIX_COLLECTOR_KEY;
|
|
179
|
+
if (typeof keyId !== "string" || !pem) {
|
|
180
|
+
deps.err("the agent needs keyId and keyFile in the config (or SAGENTIX_COLLECTOR_KEY).");
|
|
181
|
+
return 2;
|
|
182
|
+
}
|
|
183
|
+
let origin;
|
|
184
|
+
try {
|
|
185
|
+
const u = new URL(endpoint);
|
|
186
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
187
|
+
if (u.protocol !== "https:" && !(local && u.protocol === "http:"))
|
|
188
|
+
throw new Error("the endpoint must be https (http only for localhost).");
|
|
189
|
+
origin = u.origin;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
deps.err(e.message);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
const runner = detectRunner(deps.env);
|
|
196
|
+
const channel = { origin, keyId, pem, fetch: deps.channel ?? deps.fetch, dir: dirname(configPath), runner, mode: "agent", env: deps.env, now: deps.now };
|
|
197
|
+
const log = (s) => { try {
|
|
198
|
+
deps.err(s);
|
|
199
|
+
}
|
|
200
|
+
catch { /* never let logging end the agent */ } };
|
|
201
|
+
// A signed update written by a check-in ends the agent with 75 once no run is going: Docker's wrapper loop restarts
|
|
202
|
+
// the container on the new digest, a service manager restarts the script, which checks out the ref on start.
|
|
203
|
+
let restartPending = false;
|
|
204
|
+
const doCheckin = async () => {
|
|
205
|
+
try {
|
|
206
|
+
const out = await checkin(channel, { write: true, log });
|
|
207
|
+
if (out.wrote && !restartPending) {
|
|
208
|
+
restartPending = true;
|
|
209
|
+
log(`agent: exiting with ${EXIT_UPDATE} to restart on the update${session.running ? ` once run ${session.running} ends` : ""}`);
|
|
210
|
+
if (!session.running)
|
|
211
|
+
finish(EXIT_UPDATE);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
log(`agent: check-in failed: ${String(e?.message ?? e)}`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
let ws, ping, retry, stopping = false, backoff = BACKOFF_MIN_MS;
|
|
219
|
+
const send = (t) => { try {
|
|
220
|
+
if (ws && ws.readyState === OPEN) {
|
|
221
|
+
ws.send(t);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
log(`agent: send failed: ${String(e)}`);
|
|
227
|
+
} return false; };
|
|
228
|
+
const session = new AgentSession({
|
|
229
|
+
send,
|
|
230
|
+
startRun: () => io.spawnRun(configPath, deps.env),
|
|
231
|
+
checkin: doCheckin,
|
|
232
|
+
log,
|
|
233
|
+
hello: { ...collectorInfo(deps.env), runner },
|
|
234
|
+
onIdle: () => { if (restartPending)
|
|
235
|
+
finish(EXIT_UPDATE); },
|
|
236
|
+
});
|
|
237
|
+
const connect = () => {
|
|
238
|
+
retry = undefined;
|
|
239
|
+
if (stopping)
|
|
240
|
+
return;
|
|
241
|
+
let s;
|
|
242
|
+
try {
|
|
243
|
+
s = io.socket(connectUrl(origin, keyId, pem, deps.now().getTime()));
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
log(`agent: cannot connect: ${String(e?.message ?? e)}`);
|
|
247
|
+
return later();
|
|
248
|
+
}
|
|
249
|
+
ws = s;
|
|
250
|
+
s.onopen = () => {
|
|
251
|
+
log(`agent: connected to ${origin}`);
|
|
252
|
+
backoff = BACKOFF_MIN_MS;
|
|
253
|
+
try {
|
|
254
|
+
session.onOpen();
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
log(`agent: ${String(e)}`);
|
|
258
|
+
}
|
|
259
|
+
void doCheckin();
|
|
260
|
+
ping = io.setInterval(() => send(JSON.stringify({ type: "ping" })), PING_MS);
|
|
261
|
+
};
|
|
262
|
+
s.onmessage = (ev) => { try {
|
|
263
|
+
session.onMessage(ev.data);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
log(`agent: ${String(e)}`);
|
|
267
|
+
} };
|
|
268
|
+
s.onerror = () => { };
|
|
269
|
+
s.onclose = (ev) => {
|
|
270
|
+
if (ping !== undefined) {
|
|
271
|
+
io.clearInterval(ping);
|
|
272
|
+
ping = undefined;
|
|
273
|
+
}
|
|
274
|
+
if (ws === s)
|
|
275
|
+
ws = undefined;
|
|
276
|
+
if (stopping)
|
|
277
|
+
return;
|
|
278
|
+
log(`agent: disconnected${ev?.code ? ` (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` : ""}; retrying in ${Math.round(backoff / 1000)} s`);
|
|
279
|
+
later();
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const later = () => {
|
|
283
|
+
if (stopping || retry !== undefined)
|
|
284
|
+
return;
|
|
285
|
+
retry = io.setTimeout(connect, backoff);
|
|
286
|
+
backoff = nextBackoff(backoff);
|
|
287
|
+
};
|
|
288
|
+
let resolveDone = () => { };
|
|
289
|
+
function finish(code) {
|
|
290
|
+
if (stopping)
|
|
291
|
+
return;
|
|
292
|
+
stopping = true;
|
|
293
|
+
log(code === EXIT_UPDATE ? "agent: stopping for the update" : "agent: stopping");
|
|
294
|
+
io.clearInterval(periodic);
|
|
295
|
+
if (ping !== undefined)
|
|
296
|
+
io.clearInterval(ping);
|
|
297
|
+
if (retry !== undefined)
|
|
298
|
+
io.clearTimeout(retry);
|
|
299
|
+
session.stop();
|
|
300
|
+
try {
|
|
301
|
+
ws?.close(1000, "agent stopping");
|
|
302
|
+
}
|
|
303
|
+
catch { /* already closed */ }
|
|
304
|
+
resolveDone(code);
|
|
305
|
+
}
|
|
306
|
+
const periodic = io.setInterval(() => { void doCheckin(); }, CHECKIN_MS);
|
|
307
|
+
return new Promise((done) => {
|
|
308
|
+
resolveDone = done;
|
|
309
|
+
io.onStop(() => finish(0));
|
|
310
|
+
connect();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The collector's channel to sos-app beside the evidence push (apps/sos-app/docs/COLLECTOR_CHANNEL.md): who this
|
|
2
|
+
// collector is (version, commit, runner), the signed POST every message uses, and the check-in that may name a
|
|
3
|
+
// signed update. Every connection goes out; nothing here listens, and nothing the server answers can make the
|
|
4
|
+
// collector run a command or change a configuration. The most it can do is have `sagentix-collector.ref` (or
|
|
5
|
+
// `.image`) written beside the config, which the generated script reads on its next start.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { chmodSync, existsSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { arch, platform, release } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { signBody } from "./keys.js";
|
|
13
|
+
import { COLLECTOR_VERSION } from "./payload.js";
|
|
14
|
+
import { RELEASE_PUBLIC_KEYS } from "./release_keys.js";
|
|
15
|
+
export const CHECKIN_PATH = "/api/collect/checkin";
|
|
16
|
+
export const REPORT_PATH = "/api/collect/report";
|
|
17
|
+
export const CONNECT_PATH = "/api/collect/connect";
|
|
18
|
+
export const REF_FILE = "sagentix-collector.ref";
|
|
19
|
+
export const IMAGE_FILE = "sagentix-collector.image";
|
|
20
|
+
/** The agent's exit code after it wrote a signed update: the wrapper loop (Docker) or the service manager restarts it
|
|
21
|
+
* on the new image or commit (EX_TEMPFAIL, 75, from sysexits.h). `run` never exits with it. */
|
|
22
|
+
export const EXIT_UPDATE = 75;
|
|
23
|
+
export const RUNNERS = ["github", "server", "docker", "mac", "windows", "unknown"];
|
|
24
|
+
/** Set by the agent on the `run` it starts, so that run's report says `mode: "agent"`. */
|
|
25
|
+
export const MODE_ENV = "SAGENTIX_COLLECTOR_MODE";
|
|
26
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
27
|
+
/** The only image a Docker runner accepts (section 3). */
|
|
28
|
+
export const RUNNER_IMAGE_RE = /^ghcr\.io\/sagentix-advisors-inc\/sagentix-collector-runner(:[A-Za-z0-9._-]{1,128})?@sha256:[0-9a-f]{64}$/;
|
|
29
|
+
const COLLECTOR_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
let commitCache;
|
|
31
|
+
/**
|
|
32
|
+
* The commit of the collector's own checkout, or "unknown". Asked of git only when this file is tracked there, so a
|
|
33
|
+
* copy inside node_modules of someone else's repository never reports that repository's commit. The runner image
|
|
34
|
+
* has no .git; it may name its commit in SAGENTIX_COLLECTOR_COMMIT.
|
|
35
|
+
*/
|
|
36
|
+
export function collectorCommit(env = process.env) {
|
|
37
|
+
const given = env.SAGENTIX_COLLECTOR_COMMIT?.trim().toLowerCase();
|
|
38
|
+
if (given && HEX40.test(given))
|
|
39
|
+
return given;
|
|
40
|
+
if (commitCache !== undefined)
|
|
41
|
+
return commitCache;
|
|
42
|
+
commitCache = "unknown";
|
|
43
|
+
try {
|
|
44
|
+
const git = (...args) => spawnSync("git", ["-C", COLLECTOR_DIR, ...args], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] });
|
|
45
|
+
if (git("ls-files", "--error-unmatch", "src/cli.ts").status === 0) {
|
|
46
|
+
const head = git("rev-parse", "HEAD");
|
|
47
|
+
const c = head.status === 0 ? head.stdout.trim() : "";
|
|
48
|
+
if (HEX40.test(c))
|
|
49
|
+
commitCache = c;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* no git: unknown */ }
|
|
53
|
+
return commitCache;
|
|
54
|
+
}
|
|
55
|
+
export function collectorInfo(env = process.env) {
|
|
56
|
+
return { version: COLLECTOR_VERSION, commit: collectorCommit(env) };
|
|
57
|
+
}
|
|
58
|
+
/** Where this run happens: SAGENTIX_RUNNER when the generated files set it, else what the machine shows. */
|
|
59
|
+
export function detectRunner(env, plat = platform(), hasDockerEnv = () => existsSync("/.dockerenv")) {
|
|
60
|
+
const given = env.SAGENTIX_RUNNER?.trim().toLowerCase();
|
|
61
|
+
if (given && RUNNERS.includes(given))
|
|
62
|
+
return given;
|
|
63
|
+
if (env.GITHUB_ACTIONS === "true")
|
|
64
|
+
return "github";
|
|
65
|
+
if (plat === "darwin")
|
|
66
|
+
return "mac";
|
|
67
|
+
if (plat === "win32")
|
|
68
|
+
return "windows";
|
|
69
|
+
if (hasDockerEnv())
|
|
70
|
+
return "docker";
|
|
71
|
+
return "server";
|
|
72
|
+
}
|
|
73
|
+
export function modeOf(env) {
|
|
74
|
+
return env[MODE_ENV] === "agent" ? "agent" : "run";
|
|
75
|
+
}
|
|
76
|
+
export const osInfo = () => ({ platform: platform(), release: release(), arch: arch() });
|
|
77
|
+
/** POSTs exact bytes signed with the collector's key, as a push to /api/collect is. Throws on a network failure. */
|
|
78
|
+
export async function signedPost(c, path, body) {
|
|
79
|
+
const res = await c.fetch(new URL(path, c.origin).toString(), {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json", "x-sagentix-key-id": c.keyId, "x-sagentix-signature": signBody(c.pem, body) },
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
return { status: res.status, text: await res.text() };
|
|
85
|
+
}
|
|
86
|
+
/** Writes a file only its owner can read, also when it already existed with a wider mode. */
|
|
87
|
+
export function writePrivate(path, text) {
|
|
88
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
89
|
+
try {
|
|
90
|
+
chmodSync(path, 0o600);
|
|
91
|
+
}
|
|
92
|
+
catch { /* Windows: no POSIX modes */ }
|
|
93
|
+
}
|
|
94
|
+
/** The bytes the release key signs (section 4). */
|
|
95
|
+
export function releaseMessage(t) {
|
|
96
|
+
return new TextEncoder().encode(`sagentix-collector-release/1\n${t.version}\n${t.commit}\n${t.image ?? ""}\n${t.date}`);
|
|
97
|
+
}
|
|
98
|
+
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
99
|
+
/** True when the target's signature verifies against one of the release public keys. */
|
|
100
|
+
export function verifyTarget(t, keys = RELEASE_PUBLIC_KEYS) {
|
|
101
|
+
const sig = Buffer.from(t.signature, "base64");
|
|
102
|
+
if (sig.length !== 64)
|
|
103
|
+
return false;
|
|
104
|
+
const msg = releaseMessage(t);
|
|
105
|
+
return keys.some((k) => {
|
|
106
|
+
const raw = Buffer.from(k, "base64");
|
|
107
|
+
if (raw.length !== 32)
|
|
108
|
+
return false;
|
|
109
|
+
try {
|
|
110
|
+
return verify(null, msg, createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" }), sig);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function targetOf(v) {
|
|
118
|
+
if (typeof v !== "object" || v === null)
|
|
119
|
+
return "the answer names no target";
|
|
120
|
+
const t = v;
|
|
121
|
+
for (const k of ["version", "commit", "date", "signature"])
|
|
122
|
+
if (typeof t[k] !== "string" || !t[k])
|
|
123
|
+
return `the target has no ${k}`;
|
|
124
|
+
if (t.image !== null && t.image !== undefined && typeof t.image !== "string")
|
|
125
|
+
return "the target's image is not a string";
|
|
126
|
+
return { version: t.version, commit: t.commit, image: t.image ?? null, date: t.date, signature: t.signature };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* POST /api/collect/checkin, and act on the answer. Never throws and never stops a run: a network failure, a 404
|
|
130
|
+
* from an sos-app older than this collector or a malformed answer is a warning. `write: false` (a dry run) reads the
|
|
131
|
+
* answer and writes nothing.
|
|
132
|
+
*/
|
|
133
|
+
export async function checkin(c, o) {
|
|
134
|
+
const warnings = [];
|
|
135
|
+
const warn = (s) => { warnings.push(s); o.log(`warning: check-in: ${s}`); };
|
|
136
|
+
const body = new TextEncoder().encode(JSON.stringify({
|
|
137
|
+
format: "sagentix-checkin/1", issuedAt: c.now().toISOString(), collector: collectorInfo(c.env),
|
|
138
|
+
runner: c.runner, mode: c.mode, os: osInfo(), node: process.version,
|
|
139
|
+
}));
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await signedPost(c, CHECKIN_PATH, body);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
warn(`POST ${CHECKIN_PATH} failed: ${String(e?.message ?? e)}; the run goes on`);
|
|
146
|
+
return { action: "failed", warnings };
|
|
147
|
+
}
|
|
148
|
+
if (res.status !== 200) {
|
|
149
|
+
warn(`HTTP ${res.status}${res.status === 404 ? " (this sos-app does not take check-ins yet)" : ""}; the run goes on`);
|
|
150
|
+
return { action: "failed", warnings };
|
|
151
|
+
}
|
|
152
|
+
let a;
|
|
153
|
+
try {
|
|
154
|
+
a = JSON.parse(res.text);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
warn("the answer is not JSON");
|
|
158
|
+
return { action: "failed", warnings };
|
|
159
|
+
}
|
|
160
|
+
const reason = typeof a.reason === "string" ? a.reason.slice(0, 500) : "";
|
|
161
|
+
switch (a.action) {
|
|
162
|
+
case "none": return { action: "none", warnings };
|
|
163
|
+
case "notify":
|
|
164
|
+
o.log(`check-in: a newer collector is available, and this runner cannot update itself${reason ? `: ${reason}` : ""}`);
|
|
165
|
+
return { action: "notify", warnings };
|
|
166
|
+
case "approval-needed":
|
|
167
|
+
o.log(`check-in: an update waits for your organization's approval in sos-app${reason ? `: ${reason}` : ""}`);
|
|
168
|
+
return { action: "approval-needed", warnings };
|
|
169
|
+
case "update": break;
|
|
170
|
+
default:
|
|
171
|
+
warn(`unknown action ${JSON.stringify(a.action)}; nothing done`);
|
|
172
|
+
return { action: "failed", warnings };
|
|
173
|
+
}
|
|
174
|
+
const t = targetOf(a.target);
|
|
175
|
+
if (typeof t === "string") {
|
|
176
|
+
warn(`update refused: ${t}; nothing written`);
|
|
177
|
+
return { action: "update", warnings };
|
|
178
|
+
}
|
|
179
|
+
if (!HEX40.test(t.commit)) {
|
|
180
|
+
warn(`update refused: target commit ${JSON.stringify(t.commit.slice(0, 60))} is not 40 hex; nothing written`);
|
|
181
|
+
return { action: "update", warnings };
|
|
182
|
+
}
|
|
183
|
+
if (!verifyTarget(t, o.keys)) {
|
|
184
|
+
warn(`update to ${t.version} refused: its signature does not verify against a Sagentix release key; nothing written`);
|
|
185
|
+
return { action: "update", warnings };
|
|
186
|
+
}
|
|
187
|
+
const info = collectorInfo(c.env);
|
|
188
|
+
if (t.commit === info.commit)
|
|
189
|
+
return { action: "update", warnings };
|
|
190
|
+
let file, text;
|
|
191
|
+
if (c.runner === "docker") {
|
|
192
|
+
if (!t.image || !RUNNER_IMAGE_RE.test(t.image)) {
|
|
193
|
+
warn(`update to ${t.version} refused: the target names no runner image with a digest; nothing written`);
|
|
194
|
+
return { action: "update", warnings };
|
|
195
|
+
}
|
|
196
|
+
file = join(c.dir, IMAGE_FILE);
|
|
197
|
+
text = `${t.image}\n`;
|
|
198
|
+
}
|
|
199
|
+
else if (c.runner === "server" || c.runner === "mac" || c.runner === "windows") {
|
|
200
|
+
file = join(c.dir, REF_FILE);
|
|
201
|
+
text = `${t.commit}\n${t.version}\n`;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
warn(`update to ${t.version} offered, but a ${c.runner} runner pins its own commit; nothing written`);
|
|
205
|
+
return { action: "update", warnings };
|
|
206
|
+
}
|
|
207
|
+
if (!o.write) {
|
|
208
|
+
o.log(`check-in: signed update to ${t.version} (${t.commit}); dry run, so ${file} is not written`);
|
|
209
|
+
return { action: "update", warnings };
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
writePrivate(file, text);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
warn(`cannot write ${file}: ${e.code ?? String(e)}`);
|
|
216
|
+
return { action: "update", warnings };
|
|
217
|
+
}
|
|
218
|
+
o.log(`check-in: signed update to ${t.version} (${t.commit}) written to ${file}; it takes effect on the next run${reason ? ` (${reason})` : ""}`);
|
|
219
|
+
return { action: "update", wrote: file, warnings };
|
|
220
|
+
}
|