@forgezero/agent 0.1.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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cache.d.ts +124 -0
- package/dist/cache.test.d.ts +1 -0
- package/dist/fz-agent.js +392 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/pipeline.d.ts +108 -0
- package/dist/pipeline.js +81 -0
- package/dist/pipeline.test.d.ts +1 -0
- package/dist/provision.d.ts +117 -0
- package/dist/provision.js +128 -0
- package/dist/socket.d.ts +163 -0
- package/dist/socket.test.d.ts +1 -0
- package/dist/ssh-listen.d.ts +41 -0
- package/dist/ssh-listen.js +149 -0
- package/dist/ssh-listen.test.d.ts +1 -0
- package/dist/ssh-server.d.ts +84 -0
- package/dist/ssh-server.js +109 -0
- package/dist/ssh-server.test.d.ts +1 -0
- package/dist/subscribe.d.ts +101 -0
- package/dist/subscribe.js +121 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/package.json +73 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { type NodeKeyPair } from '@forgezero/runtime/identity';
|
|
3
|
+
import { startAgent, type AgentOptions, type AttestationSource } from './socket';
|
|
4
|
+
import type { SecretCache } from './cache';
|
|
5
|
+
/**
|
|
6
|
+
* fz-agent — runs on the metal, driven over SSH before the platform exists and
|
|
7
|
+
* over HTTPS after it does. One implementation, two front doors: bootstrap is
|
|
8
|
+
* not a special case, it is the general case run first, because a tenant's own
|
|
9
|
+
* bare metal has no ForgeZero on it either.
|
|
10
|
+
*
|
|
11
|
+
* Everything it does is recorded in a hash-chained journal from its first
|
|
12
|
+
* command — before there is a database to log into. A flat log written then is
|
|
13
|
+
* a log anyone could have edited afterwards; the chain either verifies or
|
|
14
|
+
* visibly does not, and it is replayed into `audit_log` once the platform is
|
|
15
|
+
* operational.
|
|
16
|
+
*
|
|
17
|
+
* ## What it serves
|
|
18
|
+
*
|
|
19
|
+
* A unix socket that SIGNS and never surrenders the key. `@forgezero/vault`
|
|
20
|
+
* discovers `/run/forgezero.sock` and prefers it over `FORGEZERO_API_KEY`, so
|
|
21
|
+
* moving an application onto managed compute means deleting an environment
|
|
22
|
+
* variable rather than changing a line of code — and a machine that used to hold
|
|
23
|
+
* a signing seed now holds nothing an attacker can take.
|
|
24
|
+
*
|
|
25
|
+
* That preference was implemented in the client long before anything listened.
|
|
26
|
+
* See `socket.ts` for why it signs rather than handing back a token.
|
|
27
|
+
*/
|
|
28
|
+
export declare const VERSION = "0.1.0";
|
|
29
|
+
export { startAgent, handleRequest } from './socket';
|
|
30
|
+
export type { AgentOptions, AttestationSource, Request, Response } from './socket';
|
|
31
|
+
export { createSecretCache, CacheError } from './cache';
|
|
32
|
+
export type { SecretCache, CacheOptions } from './cache';
|
|
33
|
+
/**
|
|
34
|
+
* The node seed, on disk, owner-only.
|
|
35
|
+
*
|
|
36
|
+
* Generated on first run rather than shipped: a seed baked into an image is a
|
|
37
|
+
* seed every guest from that image shares, and a fleet becomes one identity
|
|
38
|
+
* wearing many hostnames. Losing the file loses the node's identity, which is
|
|
39
|
+
* correct — re-enrolment is the recovery path, not a backup of the secret.
|
|
40
|
+
*/
|
|
41
|
+
export declare function loadOrCreateSeed(path: string): Uint8Array;
|
|
42
|
+
export interface AgentConfig {
|
|
43
|
+
socketPath?: string;
|
|
44
|
+
seedPath?: string;
|
|
45
|
+
/** As the platform knows this node. Absent before enrolment. */
|
|
46
|
+
nodeKey?: string;
|
|
47
|
+
attestation?: AttestationSource;
|
|
48
|
+
/**
|
|
49
|
+
* Secrets cached in memory on this guest, if this node serves any.
|
|
50
|
+
*
|
|
51
|
+
* Never written to disk. That would hand an attacker with filesystem access
|
|
52
|
+
* every secret the guest has ever read — precisely the file that does not
|
|
53
|
+
* exist today, and the whole reason a node holds a signing key rather than a
|
|
54
|
+
* `.env`. A restart re-fetches; that costs one round trip.
|
|
55
|
+
*/
|
|
56
|
+
cache?: SecretCache;
|
|
57
|
+
record?: AgentOptions['record'];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The path the agent LISTENS on, taken from the client that discovers it.
|
|
61
|
+
*
|
|
62
|
+
* Both ends declared this literal independently — `@forgezero/vault` as
|
|
63
|
+
* `DEFAULT_SOCKET` because discovery is part of its published contract, and the
|
|
64
|
+
* agent as its own constant because it is the thing that binds. Moving either
|
|
65
|
+
* one leaves an agent listening where nothing looks, and the symptom is every
|
|
66
|
+
* app silently falling back to an API key: strictly weaker, entirely working,
|
|
67
|
+
* and invisible.
|
|
68
|
+
*
|
|
69
|
+
* The published client is the authority here rather than the other way round.
|
|
70
|
+
* A tenant's application ships with `@forgezero/vault` and cannot be asked to
|
|
71
|
+
* follow a path this repository moved.
|
|
72
|
+
*/
|
|
73
|
+
export declare const DEFAULT_SOCKET_PATH = "/run/forgezero.sock";
|
|
74
|
+
export declare const DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
|
|
75
|
+
/**
|
|
76
|
+
* Bring the agent up.
|
|
77
|
+
*
|
|
78
|
+
* Returns the keys alongside the server so an enrolment flow can read the public
|
|
79
|
+
* halves without going through the socket — the same values it would get there,
|
|
80
|
+
* from the process that already holds them.
|
|
81
|
+
*/
|
|
82
|
+
export declare function runAgent(config?: AgentConfig): {
|
|
83
|
+
server: ReturnType<typeof startAgent>;
|
|
84
|
+
keys: NodeKeyPair;
|
|
85
|
+
nodeKey: string;
|
|
86
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI/CD on the compute, through the agent — and never around it.
|
|
3
|
+
*
|
|
4
|
+
* A deploy needs two things at the same time: the tenant's secrets, and the
|
|
5
|
+
* right to run commands on their machine. Every hosted CI answers that by
|
|
6
|
+
* copying the secrets INTO the runner, which is why a compromised build step is
|
|
7
|
+
* a compromised production credential everywhere that model is used.
|
|
8
|
+
*
|
|
9
|
+
* The agent already holds the secrets and already runs on the box. So the build
|
|
10
|
+
* happens here, the secrets are handed to the step's process and to nothing
|
|
11
|
+
* else, and nothing is ever written where a later step could read it.
|
|
12
|
+
*
|
|
13
|
+
* ## Attestation gates the run, not the read
|
|
14
|
+
*
|
|
15
|
+
* Reading a secret is gated by the socket's filesystem permissions, and that is
|
|
16
|
+
* proportionate — an application on this box is meant to read its own config.
|
|
17
|
+
* RUNNING A PIPELINE is different: it executes attacker-chosen commands if the
|
|
18
|
+
* pipeline definition is attacker-chosen, so the platform has to know it is
|
|
19
|
+
* talking to the machine it thinks it is.
|
|
20
|
+
*
|
|
21
|
+
* On an attested compute that is a hardware report. On one that is merely
|
|
22
|
+
* enrolled it is the node's hybrid signature, which proves possession of a key
|
|
23
|
+
* the platform issued and NOT what the machine is running. Both are honest
|
|
24
|
+
* postures and the difference is recorded on the run, so a tenant can require
|
|
25
|
+
* the stronger one — and `requireAttestation` is what makes that a refusal
|
|
26
|
+
* rather than a preference.
|
|
27
|
+
*
|
|
28
|
+
* ## Secrets never reach disk, and never reach the log
|
|
29
|
+
*
|
|
30
|
+
* They are passed as environment to the spawned process and redacted from
|
|
31
|
+
* captured output on the way back. The redaction is a last line, not the
|
|
32
|
+
* mechanism: a step that deliberately prints a secret has already been given it.
|
|
33
|
+
* What redaction buys is that a step which prints its environment while
|
|
34
|
+
* debugging does not put a production key in a log somebody ships to a vendor.
|
|
35
|
+
*/
|
|
36
|
+
export type StepOutcome = 'ok' | 'failed' | 'skipped';
|
|
37
|
+
export interface PipelineStep {
|
|
38
|
+
name: string;
|
|
39
|
+
run: string;
|
|
40
|
+
/** Secret names this step needs. Nothing it does not name is in its env. */
|
|
41
|
+
secrets?: readonly string[];
|
|
42
|
+
/** Run even when an earlier step failed — cleanup, teardown, notifications. */
|
|
43
|
+
always?: boolean;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface Pipeline {
|
|
47
|
+
name: string;
|
|
48
|
+
steps: readonly PipelineStep[];
|
|
49
|
+
/**
|
|
50
|
+
* Refuse to run at all unless the agent can produce a hardware attestation.
|
|
51
|
+
*
|
|
52
|
+
* A tenant deploying something that matters sets this. It is the difference
|
|
53
|
+
* between "a machine holding our node key ran this" and "a machine we can
|
|
54
|
+
* prove is running the image we expect ran this".
|
|
55
|
+
*/
|
|
56
|
+
requireAttestation?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface StepResult {
|
|
59
|
+
name: string;
|
|
60
|
+
outcome: StepOutcome;
|
|
61
|
+
exitCode: number | null;
|
|
62
|
+
/** Combined output, with every secret value replaced. */
|
|
63
|
+
log: string;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
}
|
|
66
|
+
export interface RunResult {
|
|
67
|
+
pipeline: string;
|
|
68
|
+
ok: boolean;
|
|
69
|
+
/** How the machine authenticated itself for this run. Recorded, not inferred. */
|
|
70
|
+
assurance: 'attested' | 'enrolled';
|
|
71
|
+
steps: readonly StepResult[];
|
|
72
|
+
}
|
|
73
|
+
export declare class PipelineError extends Error {
|
|
74
|
+
readonly code: 'ATTESTATION_REQUIRED' | 'ATTESTATION_FAILED' | 'SECRET_MISSING';
|
|
75
|
+
constructor(code: 'ATTESTATION_REQUIRED' | 'ATTESTATION_FAILED' | 'SECRET_MISSING', message: string);
|
|
76
|
+
}
|
|
77
|
+
export interface RunOptions {
|
|
78
|
+
pipeline: Pipeline;
|
|
79
|
+
/** Reads one secret. The agent's replica, so this is a memory lookup. */
|
|
80
|
+
secret(name: string): Promise<string>;
|
|
81
|
+
/** Spawns a step. Injected, so a pipeline is testable without a shell. */
|
|
82
|
+
exec(input: {
|
|
83
|
+
command: string;
|
|
84
|
+
env: Record<string, string>;
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
}): Promise<{
|
|
87
|
+
exitCode: number;
|
|
88
|
+
output: string;
|
|
89
|
+
}>;
|
|
90
|
+
/**
|
|
91
|
+
* Produces a hardware report, or throws. The agent's `attest` operation,
|
|
92
|
+
* which REFUSES when no source is configured rather than returning something
|
|
93
|
+
* attestation-shaped.
|
|
94
|
+
*/
|
|
95
|
+
attest?: () => Promise<{
|
|
96
|
+
report: string;
|
|
97
|
+
source: string;
|
|
98
|
+
}>;
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Replace every secret value wherever it appears.
|
|
103
|
+
*
|
|
104
|
+
* Longest first, so a value that contains another value does not leave the
|
|
105
|
+
* shorter one's suffix exposed after the longer one is replaced.
|
|
106
|
+
*/
|
|
107
|
+
export declare function redact(text: string, values: readonly string[]): string;
|
|
108
|
+
export declare function runPipeline(options: RunOptions): Promise<RunResult>;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/pipeline.ts
|
|
2
|
+
class PipelineError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "PipelineError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function redact(text, values) {
|
|
11
|
+
let out = text;
|
|
12
|
+
for (const value of [...values].sort((a, b) => b.length - a.length)) {
|
|
13
|
+
if (value.length < 8)
|
|
14
|
+
continue;
|
|
15
|
+
out = out.split(value).join("••••redacted••••");
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
async function runPipeline(options) {
|
|
20
|
+
const now = options.now ?? (() => Date.now());
|
|
21
|
+
const { pipeline } = options;
|
|
22
|
+
let assurance = "enrolled";
|
|
23
|
+
if (options.attest) {
|
|
24
|
+
try {
|
|
25
|
+
await options.attest();
|
|
26
|
+
assurance = "attested";
|
|
27
|
+
} catch (cause) {
|
|
28
|
+
if (pipeline.requireAttestation) {
|
|
29
|
+
throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} else if (pipeline.requireAttestation) {
|
|
33
|
+
throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
|
|
34
|
+
}
|
|
35
|
+
const steps = [];
|
|
36
|
+
let failed = false;
|
|
37
|
+
for (const step of pipeline.steps) {
|
|
38
|
+
if (failed && !step.always) {
|
|
39
|
+
steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const env = {};
|
|
43
|
+
const values = [];
|
|
44
|
+
for (const name of step.secrets ?? []) {
|
|
45
|
+
try {
|
|
46
|
+
const value = await options.secret(name);
|
|
47
|
+
env[name] = value;
|
|
48
|
+
values.push(value);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const started = now();
|
|
54
|
+
let exitCode;
|
|
55
|
+
let output;
|
|
56
|
+
try {
|
|
57
|
+
const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
|
|
58
|
+
exitCode = result.exitCode;
|
|
59
|
+
output = result.output;
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
exitCode = -1;
|
|
62
|
+
output = cause.message;
|
|
63
|
+
}
|
|
64
|
+
const outcome = exitCode === 0 ? "ok" : "failed";
|
|
65
|
+
if (outcome === "failed")
|
|
66
|
+
failed = true;
|
|
67
|
+
steps.push({
|
|
68
|
+
name: step.name,
|
|
69
|
+
outcome,
|
|
70
|
+
exitCode,
|
|
71
|
+
log: redact(output, values),
|
|
72
|
+
durationMs: now() - started
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { pipeline: pipeline.name, ok: !failed, assurance, steps };
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
runPipeline,
|
|
79
|
+
redact,
|
|
80
|
+
PipelineError
|
|
81
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a machine becomes one that can run the agent — as DATA, with no transport.
|
|
3
|
+
*
|
|
4
|
+
* There are two ways this ever happens and they must not be two implementations:
|
|
5
|
+
*
|
|
6
|
+
* fz agent install an operator, on the box, executing locally
|
|
7
|
+
* the platform enrolling a compute, executing the same steps over SSH
|
|
8
|
+
*
|
|
9
|
+
* So nothing here connects to anything. A check is a command string plus a
|
|
10
|
+
* predicate over its output; a plan is an ordered list of them. `fz` runs them
|
|
11
|
+
* with `Bun.spawn`, the control plane runs them through an SSH session, and
|
|
12
|
+
* because it is the same list, a machine provisioned by hand and one provisioned
|
|
13
|
+
* by the platform end up identical.
|
|
14
|
+
*
|
|
15
|
+
* This was briefly two implementations — a `Check`-based preflight inside the
|
|
16
|
+
* API that could run remotely, and a separate local-only planner in the CLI that
|
|
17
|
+
* called `existsSync` directly and could not. Two answers to "is this box ready"
|
|
18
|
+
* is one answer nobody can trust, and the local one would have been the one that
|
|
19
|
+
* drifted, because it is the one somebody runs while debugging.
|
|
20
|
+
*
|
|
21
|
+
* ## Every command must be non-interactive and repeatable
|
|
22
|
+
*
|
|
23
|
+
* Non-interactive because there is nobody at the terminal on the SSH path, and a
|
|
24
|
+
* prompt there does not fail — it HANGS, which is much worse. Repeatable because
|
|
25
|
+
* enrolment is retried: a half-provisioned box that cannot be re-provisioned is
|
|
26
|
+
* a box somebody rebuilds by hand.
|
|
27
|
+
*/
|
|
28
|
+
export interface Check {
|
|
29
|
+
/** Run over SSH or locally. Must be non-interactive and safe to repeat. */
|
|
30
|
+
command: string;
|
|
31
|
+
/** True when the output proves the property. */
|
|
32
|
+
satisfied: (stdout: string, exitCode: number) => boolean;
|
|
33
|
+
/** Shown when it fails. Says what to do, not merely what is wrong. */
|
|
34
|
+
remedy: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Semver-ish floor check tolerant of `v` prefixes and build suffixes.
|
|
38
|
+
*
|
|
39
|
+
* Version output is not a standard. `v1.2.3`, `1.2.3-rc1` and
|
|
40
|
+
* `bun 1.2.3 (abc123)` all mean the same thing to a human and nothing to a
|
|
41
|
+
* naive comparison, and a preflight that rejects a good box is a preflight
|
|
42
|
+
* people learn to skip.
|
|
43
|
+
*/
|
|
44
|
+
export declare function atLeast(version: string, floor: string): boolean;
|
|
45
|
+
export type AgentMode = 'attested' | 'enrolled';
|
|
46
|
+
/**
|
|
47
|
+
* What the machine can prove about itself.
|
|
48
|
+
*
|
|
49
|
+
* Expressed as checks rather than as filesystem calls precisely so the answer is
|
|
50
|
+
* the same whether it was gathered on the box or over SSH. A control plane that
|
|
51
|
+
* decided `attested` from something it could only observe locally would be
|
|
52
|
+
* deciding it from nothing at all.
|
|
53
|
+
*/
|
|
54
|
+
export declare const CAPABILITY_CHECKS: Record<string, Check> & {
|
|
55
|
+
snpGuest: Check;
|
|
56
|
+
systemd: Check;
|
|
57
|
+
bun: Check;
|
|
58
|
+
};
|
|
59
|
+
export type CapabilityId = 'snpGuest' | 'systemd' | 'bun';
|
|
60
|
+
/** The answers, however they were gathered. */
|
|
61
|
+
export type Capabilities = Record<CapabilityId, boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Which posture the agent runs in.
|
|
64
|
+
*
|
|
65
|
+
* Derived, never chosen. An operator picking `attested` would make attestation
|
|
66
|
+
* a claim, and a claim is the one thing an attestation must not be.
|
|
67
|
+
*/
|
|
68
|
+
export declare const modeFor: (capabilities: Pick<Capabilities, "snpGuest">) => AgentMode;
|
|
69
|
+
export declare const reasonFor: (mode: AgentMode) => string;
|
|
70
|
+
export interface UnitOptions {
|
|
71
|
+
mode: AgentMode;
|
|
72
|
+
socketPath: string;
|
|
73
|
+
seedPath: string;
|
|
74
|
+
/** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
|
|
75
|
+
binPath?: string;
|
|
76
|
+
user?: string;
|
|
77
|
+
apiUrl?: string;
|
|
78
|
+
project?: string;
|
|
79
|
+
environment?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A systemd unit for the agent.
|
|
83
|
+
*
|
|
84
|
+
* The hardening is not decoration. This process holds a node signing key and a
|
|
85
|
+
* project-scoped vault replica in memory, so the two things worth spending
|
|
86
|
+
* effort on are keeping that memory out of a core dump and keeping the
|
|
87
|
+
* filesystem read-only.
|
|
88
|
+
*/
|
|
89
|
+
export declare function agentUnit(options: UnitOptions): string;
|
|
90
|
+
export interface Step {
|
|
91
|
+
/** Shell, non-interactive, safe to repeat. */
|
|
92
|
+
command: string;
|
|
93
|
+
/** What it is for, in an operator's words. */
|
|
94
|
+
label: string;
|
|
95
|
+
/** A step that may fail without failing the provision. */
|
|
96
|
+
optional?: boolean;
|
|
97
|
+
}
|
|
98
|
+
export interface ProvisionPlan {
|
|
99
|
+
mode: AgentMode;
|
|
100
|
+
reason: string;
|
|
101
|
+
unitPath: string;
|
|
102
|
+
unit: string;
|
|
103
|
+
socketPath: string;
|
|
104
|
+
user: string;
|
|
105
|
+
steps: readonly Step[];
|
|
106
|
+
}
|
|
107
|
+
export declare const UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
108
|
+
/**
|
|
109
|
+
* Everything that has to happen, in order, on a machine that is going to run
|
|
110
|
+
* the agent.
|
|
111
|
+
*
|
|
112
|
+
* The last two steps are the reason this is not just "write a file and start
|
|
113
|
+
* it". A unit that starts and immediately exits is `enabled` and
|
|
114
|
+
* `active (exited)`, which reads as success at a glance — so the plan checks the
|
|
115
|
+
* service is running AND that the socket applications actually need exists.
|
|
116
|
+
*/
|
|
117
|
+
export declare function planProvision(options: UnitOptions): ProvisionPlan;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/provision.ts
|
|
2
|
+
function atLeast(version, floor) {
|
|
3
|
+
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
4
|
+
const got = parse(version);
|
|
5
|
+
const want = parse(floor);
|
|
6
|
+
if (got.length === 0)
|
|
7
|
+
return false;
|
|
8
|
+
for (let index = 0;index < want.length; index += 1) {
|
|
9
|
+
const a = got[index] ?? 0;
|
|
10
|
+
const b = want[index] ?? 0;
|
|
11
|
+
if (a > b)
|
|
12
|
+
return true;
|
|
13
|
+
if (a < b)
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
var CAPABILITY_CHECKS = {
|
|
19
|
+
snpGuest: {
|
|
20
|
+
command: "test -e /dev/sev-guest && echo yes || echo no",
|
|
21
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
22
|
+
remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
|
|
23
|
+
},
|
|
24
|
+
systemd: {
|
|
25
|
+
command: "test -d /run/systemd/system && echo yes || echo no",
|
|
26
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
27
|
+
remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
|
|
28
|
+
},
|
|
29
|
+
bun: {
|
|
30
|
+
command: "bun --version 2>/dev/null || echo missing",
|
|
31
|
+
satisfied: (stdout) => atLeast(stdout, "1.1.0"),
|
|
32
|
+
remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
36
|
+
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
|
|
37
|
+
function agentUnit(options) {
|
|
38
|
+
const bin = options.binPath ?? "fz-agent";
|
|
39
|
+
const user = options.user ?? "forgezero";
|
|
40
|
+
const environment = [
|
|
41
|
+
`FZ_SOCKET_PATH=${options.socketPath}`,
|
|
42
|
+
`FZ_SEED_PATH=${options.seedPath}`,
|
|
43
|
+
`FZ_AGENT_MODE=${options.mode}`,
|
|
44
|
+
options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
|
|
45
|
+
options.project ? `FZ_PROJECT=${options.project}` : null,
|
|
46
|
+
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null
|
|
47
|
+
].filter((line) => line !== null);
|
|
48
|
+
return `[Unit]
|
|
49
|
+
Description=ForgeZero node agent (${options.mode})
|
|
50
|
+
Documentation=https://forgezero.net/docs/agent
|
|
51
|
+
After=network-online.target
|
|
52
|
+
Wants=network-online.target
|
|
53
|
+
|
|
54
|
+
[Service]
|
|
55
|
+
Type=simple
|
|
56
|
+
User=${user}
|
|
57
|
+
Group=${user}
|
|
58
|
+
ExecStart=${bin}
|
|
59
|
+
Restart=always
|
|
60
|
+
RestartSec=2
|
|
61
|
+
|
|
62
|
+
${environment.map((line) => `Environment=${line}`).join(`
|
|
63
|
+
`)}
|
|
64
|
+
|
|
65
|
+
# The node seed and the vault replica live in this process's memory. A core dump
|
|
66
|
+
# writes both to disk, which is the one artefact this design exists to remove.
|
|
67
|
+
LimitCORE=0
|
|
68
|
+
|
|
69
|
+
# The socket is the entire interface: anything that can read it can read the
|
|
70
|
+
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
71
|
+
# than wherever the process happened to have write access.
|
|
72
|
+
RuntimeDirectory=forgezero
|
|
73
|
+
RuntimeDirectoryMode=0710
|
|
74
|
+
|
|
75
|
+
NoNewPrivileges=true
|
|
76
|
+
PrivateTmp=true
|
|
77
|
+
ProtectSystem=strict
|
|
78
|
+
ProtectHome=true
|
|
79
|
+
ProtectKernelTunables=true
|
|
80
|
+
ProtectKernelModules=true
|
|
81
|
+
ProtectControlGroups=true
|
|
82
|
+
RestrictSUIDSGID=true
|
|
83
|
+
RestrictRealtime=true
|
|
84
|
+
MemoryDenyWriteExecute=true
|
|
85
|
+
LockPersonality=true
|
|
86
|
+
ReadWritePaths=${options.seedPath.replace(/\/[^/]+$/, "")}
|
|
87
|
+
|
|
88
|
+
[Install]
|
|
89
|
+
WantedBy=multi-user.target
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
93
|
+
function planProvision(options) {
|
|
94
|
+
const mode = options.mode;
|
|
95
|
+
const user = options.user ?? "forgezero";
|
|
96
|
+
const seedDir = options.seedPath.replace(/\/[^/]+$/, "");
|
|
97
|
+
return {
|
|
98
|
+
mode,
|
|
99
|
+
reason: reasonFor(mode),
|
|
100
|
+
unitPath: UNIT_PATH,
|
|
101
|
+
unit: agentUnit({ ...options, mode }),
|
|
102
|
+
socketPath: options.socketPath,
|
|
103
|
+
user,
|
|
104
|
+
steps: [
|
|
105
|
+
{
|
|
106
|
+
label: "service account",
|
|
107
|
+
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: "seed directory",
|
|
111
|
+
command: `install -d -o ${user} -g ${user} -m 0700 ${seedDir}`
|
|
112
|
+
},
|
|
113
|
+
{ label: "reload units", command: "systemctl daemon-reload" },
|
|
114
|
+
{ label: "enable and start", command: "systemctl enable --now forgezero-agent.service" },
|
|
115
|
+
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
116
|
+
{ label: "prove the socket exists", command: `test -S ${options.socketPath}` }
|
|
117
|
+
]
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export {
|
|
121
|
+
reasonFor,
|
|
122
|
+
planProvision,
|
|
123
|
+
modeFor,
|
|
124
|
+
atLeast,
|
|
125
|
+
agentUnit,
|
|
126
|
+
UNIT_PATH,
|
|
127
|
+
CAPABILITY_CHECKS
|
|
128
|
+
};
|
package/dist/socket.d.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { type Server } from 'node:net';
|
|
2
|
+
import { signRequest, type NodeKeyPair } from '@forgezero/runtime/identity';
|
|
3
|
+
import { type Pipeline, type RunResult, type RunOptions } from './pipeline';
|
|
4
|
+
import type { SecretCache } from './cache';
|
|
5
|
+
/**
|
|
6
|
+
* The signing socket — why the application on managed compute holds nothing.
|
|
7
|
+
*
|
|
8
|
+
* `@forgezero/vault` discovers `/run/forgezero.sock` and prefers it over
|
|
9
|
+
* `FORGEZERO_API_KEY`, so moving an app onto managed compute is DELETING an
|
|
10
|
+
* environment variable. That only means anything if something is listening, and
|
|
11
|
+
* for a long time nothing was: the client knew how to prefer the socket and the
|
|
12
|
+
* agent was a version constant.
|
|
13
|
+
*
|
|
14
|
+
* ## The key never leaves
|
|
15
|
+
*
|
|
16
|
+
* There is no `getKey` operation and there is deliberately no way to add one:
|
|
17
|
+
* the socket signs, and signing is all it does. An application that could ask
|
|
18
|
+
* for the key is an application that holds the key the moment it is compromised,
|
|
19
|
+
* which is the entire difference between this and an API key in the environment.
|
|
20
|
+
*
|
|
21
|
+
* ## Why a signature and not a token
|
|
22
|
+
*
|
|
23
|
+
* A token would have to be handed to the caller, which puts it in a process we
|
|
24
|
+
* do not control, in memory we do not own, for as long as the process lives. A
|
|
25
|
+
* signature is bound to one method, one path and one body digest by
|
|
26
|
+
* `canonicalString`, so a caller that captures one cannot reuse it for anything
|
|
27
|
+
* else — and it expires with the clock-skew window rather than with the process.
|
|
28
|
+
*
|
|
29
|
+
* ## Peer credentials, not a shared secret
|
|
30
|
+
*
|
|
31
|
+
* Access control is the filesystem's: the socket is created 0600, owned by the
|
|
32
|
+
* user the agent runs as, and only that user's processes can connect. A token
|
|
33
|
+
* checked over the socket would have to live somewhere both sides can read,
|
|
34
|
+
* which is the problem this exists to remove rather than a solution to it.
|
|
35
|
+
*/
|
|
36
|
+
export declare class AgentError extends Error {
|
|
37
|
+
readonly code: string;
|
|
38
|
+
constructor(code: string, message: string);
|
|
39
|
+
}
|
|
40
|
+
/** One line of JSON in, one line of JSON out. */
|
|
41
|
+
export type Request = {
|
|
42
|
+
op: 'identity';
|
|
43
|
+
} | {
|
|
44
|
+
op: 'sign';
|
|
45
|
+
method: string;
|
|
46
|
+
path: string;
|
|
47
|
+
query?: string;
|
|
48
|
+
body?: string;
|
|
49
|
+
} | {
|
|
50
|
+
op: 'attest';
|
|
51
|
+
nonce?: string;
|
|
52
|
+
} | {
|
|
53
|
+
op: 'get';
|
|
54
|
+
name: string;
|
|
55
|
+
} | {
|
|
56
|
+
op: 'sync';
|
|
57
|
+
} | {
|
|
58
|
+
op: 'held';
|
|
59
|
+
} | {
|
|
60
|
+
op: 'run';
|
|
61
|
+
pipeline: Pipeline;
|
|
62
|
+
};
|
|
63
|
+
export type Response = {
|
|
64
|
+
ok: true;
|
|
65
|
+
op: 'identity';
|
|
66
|
+
nodeKey: string;
|
|
67
|
+
publicKeys: NodeKeyPair['ed25519'] & {
|
|
68
|
+
mlDsa: string;
|
|
69
|
+
};
|
|
70
|
+
} | {
|
|
71
|
+
ok: true;
|
|
72
|
+
op: 'sign';
|
|
73
|
+
envelope: ReturnType<typeof signRequest>;
|
|
74
|
+
} | {
|
|
75
|
+
ok: true;
|
|
76
|
+
op: 'attest';
|
|
77
|
+
report: string;
|
|
78
|
+
source: string;
|
|
79
|
+
} | {
|
|
80
|
+
ok: true;
|
|
81
|
+
op: 'get';
|
|
82
|
+
value: string;
|
|
83
|
+
} | {
|
|
84
|
+
ok: true;
|
|
85
|
+
op: 'sync';
|
|
86
|
+
invalidated: string[];
|
|
87
|
+
cursor: number;
|
|
88
|
+
resync: boolean;
|
|
89
|
+
} | {
|
|
90
|
+
ok: true;
|
|
91
|
+
op: 'held';
|
|
92
|
+
names: string[];
|
|
93
|
+
staleForMs: number;
|
|
94
|
+
} | {
|
|
95
|
+
ok: true;
|
|
96
|
+
op: 'run';
|
|
97
|
+
result: RunResult;
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
error: {
|
|
101
|
+
code: string;
|
|
102
|
+
message: string;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Produces a hardware attestation report for this guest.
|
|
107
|
+
*
|
|
108
|
+
* Injected rather than implemented here, and ABSENT by default. A SEV-SNP report
|
|
109
|
+
* is only meaningful if something verifies it against a measurement and a
|
|
110
|
+
* freshness window, and the freshness window is an open architectural question
|
|
111
|
+
* in this repo — `architecture.ts` carries it as `status: 'open'`. Inventing a
|
|
112
|
+
* number would produce a check that looks like attestation and is not one.
|
|
113
|
+
*
|
|
114
|
+
* So with no source configured, `attest` REFUSES. It does not return an empty
|
|
115
|
+
* report, and it does not silently succeed: a caller that believes it verified
|
|
116
|
+
* an attestation when nothing did is worse off than one told plainly that
|
|
117
|
+
* attestation is unavailable here.
|
|
118
|
+
*/
|
|
119
|
+
export interface AttestationSource {
|
|
120
|
+
readonly name: string;
|
|
121
|
+
report(nonce: string): Promise<string>;
|
|
122
|
+
}
|
|
123
|
+
export interface AgentOptions {
|
|
124
|
+
socketPath: string;
|
|
125
|
+
keys: NodeKeyPair;
|
|
126
|
+
/** The node's identifier, as the platform knows it. */
|
|
127
|
+
nodeKey: string;
|
|
128
|
+
attestation?: AttestationSource;
|
|
129
|
+
/**
|
|
130
|
+
* Runs a pipeline step. ABSENT by default, and that is the security posture.
|
|
131
|
+
*
|
|
132
|
+
* An agent that can execute arbitrary commands is a remote shell with a vault
|
|
133
|
+
* attached. Shipping one on by default would mean every compute that installs
|
|
134
|
+
* the agent for its secrets also accepts remote execution, which is not the
|
|
135
|
+
* trade anybody agreed to. An operator turns it on for the boxes that deploy.
|
|
136
|
+
*/
|
|
137
|
+
exec?: RunOptions['exec'];
|
|
138
|
+
/**
|
|
139
|
+
* Secrets, cached in memory on this guest.
|
|
140
|
+
*
|
|
141
|
+
* Optional: an agent that only signs is a complete agent, and a box with no
|
|
142
|
+
* project bound to it has nothing to cache. When absent, `get` says so rather
|
|
143
|
+
* than returning nothing — an application that receives an empty value for a
|
|
144
|
+
* database URL fails somewhere far from here.
|
|
145
|
+
*/
|
|
146
|
+
cache?: SecretCache;
|
|
147
|
+
/** Every request, for the hash-chained journal. */
|
|
148
|
+
record?: (entry: {
|
|
149
|
+
op: string;
|
|
150
|
+
outcome: 'ok' | 'refused';
|
|
151
|
+
detail?: string;
|
|
152
|
+
}) => void;
|
|
153
|
+
}
|
|
154
|
+
export declare function handleRequest(options: AgentOptions, request: Request): Promise<Response>;
|
|
155
|
+
/**
|
|
156
|
+
* Listen, with the socket file replaced rather than reused.
|
|
157
|
+
*
|
|
158
|
+
* A stale socket from a killed process makes `listen` fail with EADDRINUSE, and
|
|
159
|
+
* an agent that will not start after an unclean shutdown is an outage that needs
|
|
160
|
+
* a human. The file is unlinked first — safe because only one agent runs per
|
|
161
|
+
* guest, and a live one would be holding the path.
|
|
162
|
+
*/
|
|
163
|
+
export declare function startAgent(options: AgentOptions): Server;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|