@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Server } from 'node:net';
|
|
2
|
+
import { MAX_MESSAGE_BYTES, type AgentBackend } from './ssh-server';
|
|
3
|
+
/**
|
|
4
|
+
* The socket `SSH_AUTH_SOCK` points at.
|
|
5
|
+
*
|
|
6
|
+
* `ssh-server.ts` speaks the protocol and had nowhere to speak it: the message
|
|
7
|
+
* handler was complete, tested, and nothing ever listened. An agent protocol
|
|
8
|
+
* with no socket is a parser.
|
|
9
|
+
*
|
|
10
|
+
* ## Framing is a stream problem, not a message problem
|
|
11
|
+
*
|
|
12
|
+
* A unix socket delivers bytes, not messages. `ssh` pipelines requests, and a
|
|
13
|
+
* single `data` event can carry two of them or half of one — so the buffer is
|
|
14
|
+
* drained in a loop until `readMessage` says it needs more. Handling one
|
|
15
|
+
* message per event works in every manual test and fails the moment a real
|
|
16
|
+
* client is fast.
|
|
17
|
+
*
|
|
18
|
+
* ## The DIRECTORY is the access control, not the socket
|
|
19
|
+
*
|
|
20
|
+
* Anything that can open this socket can sign with every key the vault holds.
|
|
21
|
+
* There is no authentication inside the protocol and there is not meant to be:
|
|
22
|
+
* the filesystem is the boundary, exactly as OpenSSH's own agent does it.
|
|
23
|
+
*
|
|
24
|
+
* Chmodding the socket alone is not enough, and measuring it showed why — the
|
|
25
|
+
* file appears when `listen` binds and the mode is only corrected in the
|
|
26
|
+
* callback afterwards, so it exists world-connectable for a moment first. A
|
|
27
|
+
* brief window is still a window on a machine where anything can retry.
|
|
28
|
+
*
|
|
29
|
+
* So the containing directory is created 0700 BEFORE binding. Traversal is
|
|
30
|
+
* denied for everybody else regardless of what the socket's own bits say, which
|
|
31
|
+
* is why `ssh-agent` puts its socket in a private directory too. The socket is
|
|
32
|
+
* chmodded as well, because two boundaries cost nothing.
|
|
33
|
+
*/
|
|
34
|
+
export interface SshListenOptions {
|
|
35
|
+
socketPath: string;
|
|
36
|
+
backend: AgentBackend;
|
|
37
|
+
/** Reported per connection failure. Never the key, never the data. */
|
|
38
|
+
onError?: (error: Error) => void;
|
|
39
|
+
}
|
|
40
|
+
export declare function startSshAgent(options: SshListenOptions): Server;
|
|
41
|
+
export { MAX_MESSAGE_BYTES };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/ssh-server.ts
|
|
2
|
+
import { authorizedKey, publicKeyBlob, signatureBlob } from "@forgezero/runtime/openssh";
|
|
3
|
+
var SSH_AGENT_FAILURE = 5;
|
|
4
|
+
var SSH_AGENT_SUCCESS = 6;
|
|
5
|
+
var SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
6
|
+
var SSH_AGENT_IDENTITIES_ANSWER = 12;
|
|
7
|
+
var SSH_AGENTC_SIGN_REQUEST = 13;
|
|
8
|
+
var SSH_AGENT_SIGN_RESPONSE = 14;
|
|
9
|
+
var MAX_MESSAGE_BYTES = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
class SshAgentError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "SshAgentError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
var u32 = (value) => {
|
|
20
|
+
const out = new Uint8Array(4);
|
|
21
|
+
new DataView(out.buffer).setUint32(0, value, false);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
var concat = (...parts) => {
|
|
25
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
out.set(part, offset);
|
|
29
|
+
offset += part.length;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
var sshString = (value) => {
|
|
34
|
+
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
35
|
+
return concat(u32(bytes.length), bytes);
|
|
36
|
+
};
|
|
37
|
+
var frame = (payload) => concat(u32(payload.length), payload);
|
|
38
|
+
function readMessage(buffer) {
|
|
39
|
+
if (buffer.length < 4)
|
|
40
|
+
return null;
|
|
41
|
+
const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false);
|
|
42
|
+
if (length > MAX_MESSAGE_BYTES) {
|
|
43
|
+
throw new SshAgentError("TOO_LARGE", `A ${length}-byte agent message is past the limit.`);
|
|
44
|
+
}
|
|
45
|
+
if (buffer.length < 4 + length)
|
|
46
|
+
return null;
|
|
47
|
+
return { message: buffer.subarray(4, 4 + length), rest: buffer.subarray(4 + length) };
|
|
48
|
+
}
|
|
49
|
+
var failure = () => frame(Uint8Array.from([SSH_AGENT_FAILURE]));
|
|
50
|
+
async function handleMessage(message, backend) {
|
|
51
|
+
if (message.length === 0)
|
|
52
|
+
return failure();
|
|
53
|
+
switch (message[0]) {
|
|
54
|
+
case SSH_AGENTC_REQUEST_IDENTITIES: {
|
|
55
|
+
const identities = await backend.identities();
|
|
56
|
+
const body = identities.flatMap((identity) => [
|
|
57
|
+
sshString(publicKeyBlob(identity.publicKey)),
|
|
58
|
+
sshString(identity.comment)
|
|
59
|
+
]);
|
|
60
|
+
return frame(concat(Uint8Array.from([SSH_AGENT_IDENTITIES_ANSWER]), u32(identities.length), ...body));
|
|
61
|
+
}
|
|
62
|
+
case SSH_AGENTC_SIGN_REQUEST: {
|
|
63
|
+
const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
|
|
64
|
+
let offset = 1;
|
|
65
|
+
const read = () => {
|
|
66
|
+
if (offset + 4 > message.length)
|
|
67
|
+
throw new SshAgentError("TRUNCATED", "Short sign request.");
|
|
68
|
+
const length = view.getUint32(offset, false);
|
|
69
|
+
offset += 4;
|
|
70
|
+
if (offset + length > message.length) {
|
|
71
|
+
throw new SshAgentError("TRUNCATED", "Sign request claims more bytes than it carries.");
|
|
72
|
+
}
|
|
73
|
+
const slice = message.subarray(offset, offset + length);
|
|
74
|
+
offset += length;
|
|
75
|
+
return slice;
|
|
76
|
+
};
|
|
77
|
+
let keyBlob;
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
keyBlob = read();
|
|
81
|
+
data = read();
|
|
82
|
+
} catch {
|
|
83
|
+
return failure();
|
|
84
|
+
}
|
|
85
|
+
if (keyBlob.length < 32)
|
|
86
|
+
return failure();
|
|
87
|
+
const publicKey = keyBlob.subarray(keyBlob.length - 32);
|
|
88
|
+
const signature = await backend.sign(publicKey, data);
|
|
89
|
+
if (!signature)
|
|
90
|
+
return failure();
|
|
91
|
+
return frame(concat(Uint8Array.from([SSH_AGENT_SIGN_RESPONSE]), sshString(signatureBlob(signature))));
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
return failure();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
var describeIdentities = (identities) => identities.map((identity) => authorizedKey(identity.publicKey, identity.comment));
|
|
98
|
+
// src/ssh-listen.ts
|
|
99
|
+
import { createServer } from "node:net";
|
|
100
|
+
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
101
|
+
import { dirname } from "node:path";
|
|
102
|
+
function startSshAgent(options) {
|
|
103
|
+
const directory = dirname(options.socketPath);
|
|
104
|
+
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
105
|
+
chmodSync(directory, 448);
|
|
106
|
+
if (existsSync(options.socketPath))
|
|
107
|
+
unlinkSync(options.socketPath);
|
|
108
|
+
const server = createServer((socket) => {
|
|
109
|
+
let buffer = new Uint8Array(0);
|
|
110
|
+
socket.on("data", async (chunk) => {
|
|
111
|
+
const incoming = new Uint8Array(chunk);
|
|
112
|
+
const merged = new Uint8Array(buffer.length + incoming.length);
|
|
113
|
+
merged.set(buffer);
|
|
114
|
+
merged.set(incoming, buffer.length);
|
|
115
|
+
buffer = merged;
|
|
116
|
+
for (;; ) {
|
|
117
|
+
let next;
|
|
118
|
+
try {
|
|
119
|
+
next = readMessage(buffer);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
options.onError?.(error);
|
|
122
|
+
socket.destroy();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!next)
|
|
126
|
+
return;
|
|
127
|
+
buffer = next.rest;
|
|
128
|
+
try {
|
|
129
|
+
const reply = await handleMessage(next.message, options.backend);
|
|
130
|
+
socket.write(Buffer.from(reply.slice().buffer));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
options.onError?.(error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
socket.on("error", () => socket.destroy());
|
|
137
|
+
socket.on("close", () => {
|
|
138
|
+
buffer = new Uint8Array(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
server.listen(options.socketPath, () => {
|
|
142
|
+
chmodSync(options.socketPath, 384);
|
|
143
|
+
});
|
|
144
|
+
return server;
|
|
145
|
+
}
|
|
146
|
+
export {
|
|
147
|
+
startSshAgent,
|
|
148
|
+
MAX_MESSAGE_BYTES
|
|
149
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH agent protocol, served rather than consumed.
|
|
3
|
+
*
|
|
4
|
+
* `ssh` talks to whatever `SSH_AUTH_SOCK` points at. Pointing it here means a
|
|
5
|
+
* developer's key lives in the vault and is used without ever existing on their
|
|
6
|
+
* disk — which removes the artefact that every leaked SSH key came from.
|
|
7
|
+
*
|
|
8
|
+
* ## Two operations, and the omissions are the design
|
|
9
|
+
*
|
|
10
|
+
* `REQUEST_IDENTITIES` and `SIGN_REQUEST` are implemented. `ADD_IDENTITY`,
|
|
11
|
+
* `REMOVE_IDENTITY` and `REMOVE_ALL_IDENTITIES` are deliberately NOT: this
|
|
12
|
+
* agent's keys come from the vault, and a client that could add one would let
|
|
13
|
+
* any process on the machine inject a key that `ssh` would then offer to every
|
|
14
|
+
* host the user connects to. Refusing them is not a missing feature, it is the
|
|
15
|
+
* difference between an agent and a shared keyring.
|
|
16
|
+
*
|
|
17
|
+
* Lock and unlock are also absent. An agent that can be locked by a message can
|
|
18
|
+
* be locked by anything that reaches the socket, and the socket is already the
|
|
19
|
+
* boundary — filesystem permissions decide who may speak here.
|
|
20
|
+
*
|
|
21
|
+
* ## Framing is length-prefixed and bounded
|
|
22
|
+
*
|
|
23
|
+
* Every message is a 4-byte big-endian length then that many bytes. A length
|
|
24
|
+
* field is attacker-controlled, so it is bounded before a buffer is allocated:
|
|
25
|
+
* an unbounded read is a one-packet memory exhaustion against a process holding
|
|
26
|
+
* a vault replica.
|
|
27
|
+
*/
|
|
28
|
+
declare const SSH_AGENT_FAILURE = 5;
|
|
29
|
+
declare const SSH_AGENT_SUCCESS = 6;
|
|
30
|
+
declare const SSH_AGENT_IDENTITIES_ANSWER = 12;
|
|
31
|
+
declare const SSH_AGENT_SIGN_RESPONSE = 14;
|
|
32
|
+
/**
|
|
33
|
+
* The largest message worth reading.
|
|
34
|
+
*
|
|
35
|
+
* An SSH signing request is a session identifier and a little framing — a few
|
|
36
|
+
* hundred bytes. 256 KiB is far above anything legitimate and far below a size
|
|
37
|
+
* that matters, and the point is that the bound exists at all.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_MESSAGE_BYTES: number;
|
|
40
|
+
export declare class SshAgentError extends Error {
|
|
41
|
+
readonly code: 'TOO_LARGE' | 'TRUNCATED' | 'UNSUPPORTED';
|
|
42
|
+
constructor(code: 'TOO_LARGE' | 'TRUNCATED' | 'UNSUPPORTED', message: string);
|
|
43
|
+
}
|
|
44
|
+
/** An identity this agent will offer. The private half is never here. */
|
|
45
|
+
export interface AgentIdentity {
|
|
46
|
+
/** 32 raw bytes. */
|
|
47
|
+
publicKey: Uint8Array;
|
|
48
|
+
comment: string;
|
|
49
|
+
}
|
|
50
|
+
export interface AgentBackend {
|
|
51
|
+
identities(): Promise<readonly AgentIdentity[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Sign `data` with the key matching `publicKey`, or return null.
|
|
54
|
+
*
|
|
55
|
+
* Null rather than throwing for an unknown key: `ssh` offers every identity
|
|
56
|
+
* in turn and expects a failure for the ones a server did not accept, so an
|
|
57
|
+
* exception here would turn normal negotiation into a crash.
|
|
58
|
+
*/
|
|
59
|
+
sign(publicKey: Uint8Array, data: Uint8Array): Promise<Uint8Array | null>;
|
|
60
|
+
}
|
|
61
|
+
/** Wrap a payload in the outer length prefix the protocol frames with. */
|
|
62
|
+
export declare const frame: (payload: Uint8Array) => Uint8Array;
|
|
63
|
+
/**
|
|
64
|
+
* Pull one complete message out of a buffer.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when more bytes are needed — a socket delivers whatever arrived,
|
|
67
|
+
* not whatever was sent, and treating a partial read as a malformed message is
|
|
68
|
+
* how an agent drops every request larger than one TCP segment.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readMessage(buffer: Uint8Array): {
|
|
71
|
+
message: Uint8Array;
|
|
72
|
+
rest: Uint8Array;
|
|
73
|
+
} | null;
|
|
74
|
+
/**
|
|
75
|
+
* Answer one message.
|
|
76
|
+
*
|
|
77
|
+
* Every unknown or unimplemented request answers FAILURE rather than closing
|
|
78
|
+
* the connection. `ssh` probes an agent's capabilities, and a socket that hangs
|
|
79
|
+
* up on an unexpected byte makes every client think the agent has died.
|
|
80
|
+
*/
|
|
81
|
+
export declare function handleMessage(message: Uint8Array, backend: AgentBackend): Promise<Uint8Array>;
|
|
82
|
+
/** What `ssh-add -l` would print, for an operator checking what is offered. */
|
|
83
|
+
export declare const describeIdentities: (identities: readonly AgentIdentity[]) => string[];
|
|
84
|
+
export { SSH_AGENT_FAILURE, SSH_AGENT_SUCCESS, SSH_AGENT_IDENTITIES_ANSWER, SSH_AGENT_SIGN_RESPONSE };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/ssh-server.ts
|
|
2
|
+
import { authorizedKey, publicKeyBlob, signatureBlob } from "@forgezero/runtime/openssh";
|
|
3
|
+
var SSH_AGENT_FAILURE = 5;
|
|
4
|
+
var SSH_AGENT_SUCCESS = 6;
|
|
5
|
+
var SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
6
|
+
var SSH_AGENT_IDENTITIES_ANSWER = 12;
|
|
7
|
+
var SSH_AGENTC_SIGN_REQUEST = 13;
|
|
8
|
+
var SSH_AGENT_SIGN_RESPONSE = 14;
|
|
9
|
+
var MAX_MESSAGE_BYTES = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
class SshAgentError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "SshAgentError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
var u32 = (value) => {
|
|
20
|
+
const out = new Uint8Array(4);
|
|
21
|
+
new DataView(out.buffer).setUint32(0, value, false);
|
|
22
|
+
return out;
|
|
23
|
+
};
|
|
24
|
+
var concat = (...parts) => {
|
|
25
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
26
|
+
let offset = 0;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
out.set(part, offset);
|
|
29
|
+
offset += part.length;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
var sshString = (value) => {
|
|
34
|
+
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
35
|
+
return concat(u32(bytes.length), bytes);
|
|
36
|
+
};
|
|
37
|
+
var frame = (payload) => concat(u32(payload.length), payload);
|
|
38
|
+
function readMessage(buffer) {
|
|
39
|
+
if (buffer.length < 4)
|
|
40
|
+
return null;
|
|
41
|
+
const length = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false);
|
|
42
|
+
if (length > MAX_MESSAGE_BYTES) {
|
|
43
|
+
throw new SshAgentError("TOO_LARGE", `A ${length}-byte agent message is past the limit.`);
|
|
44
|
+
}
|
|
45
|
+
if (buffer.length < 4 + length)
|
|
46
|
+
return null;
|
|
47
|
+
return { message: buffer.subarray(4, 4 + length), rest: buffer.subarray(4 + length) };
|
|
48
|
+
}
|
|
49
|
+
var failure = () => frame(Uint8Array.from([SSH_AGENT_FAILURE]));
|
|
50
|
+
async function handleMessage(message, backend) {
|
|
51
|
+
if (message.length === 0)
|
|
52
|
+
return failure();
|
|
53
|
+
switch (message[0]) {
|
|
54
|
+
case SSH_AGENTC_REQUEST_IDENTITIES: {
|
|
55
|
+
const identities = await backend.identities();
|
|
56
|
+
const body = identities.flatMap((identity) => [
|
|
57
|
+
sshString(publicKeyBlob(identity.publicKey)),
|
|
58
|
+
sshString(identity.comment)
|
|
59
|
+
]);
|
|
60
|
+
return frame(concat(Uint8Array.from([SSH_AGENT_IDENTITIES_ANSWER]), u32(identities.length), ...body));
|
|
61
|
+
}
|
|
62
|
+
case SSH_AGENTC_SIGN_REQUEST: {
|
|
63
|
+
const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
|
|
64
|
+
let offset = 1;
|
|
65
|
+
const read = () => {
|
|
66
|
+
if (offset + 4 > message.length)
|
|
67
|
+
throw new SshAgentError("TRUNCATED", "Short sign request.");
|
|
68
|
+
const length = view.getUint32(offset, false);
|
|
69
|
+
offset += 4;
|
|
70
|
+
if (offset + length > message.length) {
|
|
71
|
+
throw new SshAgentError("TRUNCATED", "Sign request claims more bytes than it carries.");
|
|
72
|
+
}
|
|
73
|
+
const slice = message.subarray(offset, offset + length);
|
|
74
|
+
offset += length;
|
|
75
|
+
return slice;
|
|
76
|
+
};
|
|
77
|
+
let keyBlob;
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
keyBlob = read();
|
|
81
|
+
data = read();
|
|
82
|
+
} catch {
|
|
83
|
+
return failure();
|
|
84
|
+
}
|
|
85
|
+
if (keyBlob.length < 32)
|
|
86
|
+
return failure();
|
|
87
|
+
const publicKey = keyBlob.subarray(keyBlob.length - 32);
|
|
88
|
+
const signature = await backend.sign(publicKey, data);
|
|
89
|
+
if (!signature)
|
|
90
|
+
return failure();
|
|
91
|
+
return frame(concat(Uint8Array.from([SSH_AGENT_SIGN_RESPONSE]), sshString(signatureBlob(signature))));
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
return failure();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
var describeIdentities = (identities) => identities.map((identity) => authorizedKey(identity.publicKey, identity.comment));
|
|
98
|
+
export {
|
|
99
|
+
readMessage,
|
|
100
|
+
handleMessage,
|
|
101
|
+
frame,
|
|
102
|
+
describeIdentities,
|
|
103
|
+
SshAgentError,
|
|
104
|
+
SSH_AGENT_SUCCESS,
|
|
105
|
+
SSH_AGENT_SIGN_RESPONSE,
|
|
106
|
+
SSH_AGENT_IDENTITIES_ANSWER,
|
|
107
|
+
SSH_AGENT_FAILURE,
|
|
108
|
+
MAX_MESSAGE_BYTES
|
|
109
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime scope sync — a WebSocket subscription, with polling as the floor.
|
|
3
|
+
*
|
|
4
|
+
* The agent holds the whole assigned project and environment in RAM. Keeping
|
|
5
|
+
* that current by polling a cursor means a rotation is live somewhere between
|
|
6
|
+
* zero and one interval after it happens, and the interval cannot be shortened
|
|
7
|
+
* far because every agent on the fleet is asking. A subscription inverts it: the
|
|
8
|
+
* platform pushes, so propagation is a network hop and an idle agent costs one
|
|
9
|
+
* open socket instead of a request every few seconds.
|
|
10
|
+
*
|
|
11
|
+
* ## Polling is not deleted, and that is the whole design
|
|
12
|
+
*
|
|
13
|
+
* A WebSocket is the fast path, never the only one. It fails in ways an HTTP
|
|
14
|
+
* request does not — a proxy that idles it out, a load balancer that drops it, a
|
|
15
|
+
* network that half-closes so the socket looks open and delivers nothing. Every
|
|
16
|
+
* one of those is silent, and a silent sync failure on a vault replica means an
|
|
17
|
+
* application confidently serving a credential that was revoked an hour ago.
|
|
18
|
+
*
|
|
19
|
+
* So the subscription is a latency optimisation over a poll that keeps running:
|
|
20
|
+
*
|
|
21
|
+
* · the socket delivers changes as they happen
|
|
22
|
+
* · the poll continues at a LONG interval and is what actually establishes
|
|
23
|
+
* freshness — `lastSyncOkMs` moves on a poll, not on a frame
|
|
24
|
+
* · a poll that finds changes the socket should have delivered is proof the
|
|
25
|
+
* socket is lying, and it is torn down and reopened
|
|
26
|
+
*
|
|
27
|
+
* That last point is the one worth the code. A half-open socket is
|
|
28
|
+
* indistinguishable from a quiet one, and the only way to tell them apart is to
|
|
29
|
+
* ask over a channel that is known to work.
|
|
30
|
+
*
|
|
31
|
+
* ## Freshness is never asserted by a frame
|
|
32
|
+
*
|
|
33
|
+
* A frame proves the platform sent something. It does not prove this agent is
|
|
34
|
+
* seeing everything, because an attacker who can drop frames silently can hold a
|
|
35
|
+
* replica stale for as long as they like. The staleness clock therefore only
|
|
36
|
+
* advances on a successful poll — an authenticated round trip the agent
|
|
37
|
+
* initiated — and `get` still refuses when that clock runs out. A subscription
|
|
38
|
+
* can make the replica fresher; it can never make it *vouchable*.
|
|
39
|
+
*/
|
|
40
|
+
export type ChangeFrame = {
|
|
41
|
+
type: 'changed';
|
|
42
|
+
names: readonly string[];
|
|
43
|
+
cursor: number;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'resync';
|
|
46
|
+
reason?: string;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'revoked';
|
|
49
|
+
reason?: string;
|
|
50
|
+
};
|
|
51
|
+
export interface Subscriber {
|
|
52
|
+
/** Close and stop reconnecting. */
|
|
53
|
+
stop(): void;
|
|
54
|
+
/** For status output: is the socket currently up? */
|
|
55
|
+
readonly live: boolean;
|
|
56
|
+
/** How many times it has reconnected. A climbing number is a bad network. */
|
|
57
|
+
readonly reconnects: number;
|
|
58
|
+
}
|
|
59
|
+
export interface SubscribeOptions {
|
|
60
|
+
url: string;
|
|
61
|
+
/**
|
|
62
|
+
* Opens a socket. Injected so this is testable without a server, and so a
|
|
63
|
+
* runtime with its own WebSocket can supply it.
|
|
64
|
+
*/
|
|
65
|
+
open(url: string): SocketLike;
|
|
66
|
+
/** Applied when a frame arrives. */
|
|
67
|
+
onFrame(frame: ChangeFrame): void | Promise<void>;
|
|
68
|
+
/** The poll that establishes freshness. Runs regardless of the socket. */
|
|
69
|
+
poll(): Promise<{
|
|
70
|
+
invalidated: string[];
|
|
71
|
+
resync: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
/** Long, because the socket is doing the fast work. */
|
|
74
|
+
pollIntervalMs?: number;
|
|
75
|
+
/** Backoff ceiling. */
|
|
76
|
+
maxBackoffMs?: number;
|
|
77
|
+
onEvent?: (event: string, detail?: unknown) => void;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
80
|
+
clearTimer?: (handle: unknown) => void;
|
|
81
|
+
}
|
|
82
|
+
export interface SocketLike {
|
|
83
|
+
close(): void;
|
|
84
|
+
onmessage: ((event: {
|
|
85
|
+
data: string;
|
|
86
|
+
}) => void) | null;
|
|
87
|
+
onopen: (() => void) | null;
|
|
88
|
+
onclose: (() => void) | null;
|
|
89
|
+
onerror: ((error: unknown) => void) | null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reconnect with exponential backoff and full jitter.
|
|
93
|
+
*
|
|
94
|
+
* Jitter is not politeness. Every agent on a fleet loses its socket at the same
|
|
95
|
+
* instant when the platform restarts, and without jitter every one of them
|
|
96
|
+
* reconnects at the same instant too — turning a deploy into a self-inflicted
|
|
97
|
+
* denial of service at exactly the moment the platform is least able to absorb
|
|
98
|
+
* it. Full jitter across the whole window spreads them out.
|
|
99
|
+
*/
|
|
100
|
+
export declare function backoffMs(attempt: number, ceiling: number, random: () => number): number;
|
|
101
|
+
export declare function subscribe(options: SubscribeOptions): Subscriber;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// src/subscribe.ts
|
|
2
|
+
var DEFAULT_POLL_MS = 60000;
|
|
3
|
+
var DEFAULT_MAX_BACKOFF_MS = 30000;
|
|
4
|
+
function backoffMs(attempt, ceiling, random) {
|
|
5
|
+
const window = Math.min(ceiling, 500 * 2 ** Math.min(attempt, 10));
|
|
6
|
+
return Math.floor(random() * window);
|
|
7
|
+
}
|
|
8
|
+
function subscribe(options) {
|
|
9
|
+
const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
10
|
+
const ceiling = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
11
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
12
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
13
|
+
const emit = options.onEvent ?? (() => {});
|
|
14
|
+
let socket = null;
|
|
15
|
+
let stopped = false;
|
|
16
|
+
let attempt = 0;
|
|
17
|
+
let live = false;
|
|
18
|
+
let reconnects = 0;
|
|
19
|
+
let pollTimer = null;
|
|
20
|
+
let retryTimer = null;
|
|
21
|
+
let deliveredSincePoll = new Set;
|
|
22
|
+
const teardown = () => {
|
|
23
|
+
if (socket) {
|
|
24
|
+
socket.onmessage = null;
|
|
25
|
+
socket.onopen = null;
|
|
26
|
+
socket.onclose = null;
|
|
27
|
+
socket.onerror = null;
|
|
28
|
+
try {
|
|
29
|
+
socket.close();
|
|
30
|
+
} catch {}
|
|
31
|
+
socket = null;
|
|
32
|
+
}
|
|
33
|
+
live = false;
|
|
34
|
+
};
|
|
35
|
+
const connect = () => {
|
|
36
|
+
if (stopped)
|
|
37
|
+
return;
|
|
38
|
+
teardown();
|
|
39
|
+
let opened;
|
|
40
|
+
try {
|
|
41
|
+
opened = options.open(options.url);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
emit("open-failed", error);
|
|
44
|
+
scheduleRetry();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
socket = opened;
|
|
48
|
+
opened.onopen = () => {
|
|
49
|
+
live = true;
|
|
50
|
+
attempt = 0;
|
|
51
|
+
emit("open");
|
|
52
|
+
};
|
|
53
|
+
opened.onmessage = (event) => {
|
|
54
|
+
let frame;
|
|
55
|
+
try {
|
|
56
|
+
frame = JSON.parse(event.data);
|
|
57
|
+
} catch {
|
|
58
|
+
emit("bad-frame");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (frame.type === "changed")
|
|
62
|
+
for (const name of frame.names)
|
|
63
|
+
deliveredSincePoll.add(name);
|
|
64
|
+
options.onFrame(frame);
|
|
65
|
+
};
|
|
66
|
+
opened.onerror = (error) => emit("error", error);
|
|
67
|
+
opened.onclose = () => {
|
|
68
|
+
live = false;
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
71
|
+
reconnects += 1;
|
|
72
|
+
emit("closed");
|
|
73
|
+
scheduleRetry();
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const scheduleRetry = () => {
|
|
77
|
+
if (stopped)
|
|
78
|
+
return;
|
|
79
|
+
attempt += 1;
|
|
80
|
+
retryTimer = setTimer(connect, backoffMs(attempt, ceiling, Math.random));
|
|
81
|
+
};
|
|
82
|
+
const pollOnce = async () => {
|
|
83
|
+
if (stopped)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const result = await options.poll();
|
|
87
|
+
const missed = result.invalidated.filter((name) => !deliveredSincePoll.has(name));
|
|
88
|
+
if (live && (missed.length > 0 || result.resync)) {
|
|
89
|
+
emit("socket-missed-changes", missed);
|
|
90
|
+
teardown();
|
|
91
|
+
connect();
|
|
92
|
+
}
|
|
93
|
+
deliveredSincePoll = new Set;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
emit("poll-failed", error);
|
|
96
|
+
} finally {
|
|
97
|
+
if (!stopped)
|
|
98
|
+
pollTimer = setTimer(() => void pollOnce(), pollInterval);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
connect();
|
|
102
|
+
pollTimer = setTimer(() => void pollOnce(), pollInterval);
|
|
103
|
+
return {
|
|
104
|
+
stop() {
|
|
105
|
+
stopped = true;
|
|
106
|
+
teardown();
|
|
107
|
+
clearTimer(pollTimer);
|
|
108
|
+
clearTimer(retryTimer);
|
|
109
|
+
},
|
|
110
|
+
get live() {
|
|
111
|
+
return live;
|
|
112
|
+
},
|
|
113
|
+
get reconnects() {
|
|
114
|
+
return reconnects;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
subscribe,
|
|
120
|
+
backoffMs
|
|
121
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
|
+
"name": "@forgezero/agent",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"check": "tsc --noEmit",
|
|
8
|
+
"build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm --packages external && bun build src/provision.ts src/subscribe.ts src/pipeline.ts src/ssh-server.ts src/ssh-listen.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
9
|
+
"prepublishOnly": "bun run check && bun run build"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"typescript": "^5.6.0",
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
"@types/node": "^22.0.0",
|
|
15
|
+
"@noble/curves": "^2.2.0",
|
|
16
|
+
"@noble/post-quantum": "^0.6.1"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@forgezero/runtime": "^0.1.0",
|
|
20
|
+
"@forgezero/vault": "^0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"fz-agent": "./dist/fz-agent.js"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"description": "The ForgeZero node agent \u2014 holds a project-scoped vault replica in RAM and serves it over a unix socket, so applications hold no credential.",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"agent",
|
|
37
|
+
"forgezero",
|
|
38
|
+
"vault",
|
|
39
|
+
"secrets",
|
|
40
|
+
"systemd",
|
|
41
|
+
"attestation",
|
|
42
|
+
"sev-snp"
|
|
43
|
+
],
|
|
44
|
+
"homepage": "https://forgezero.net/docs/agent",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/axxra/forgezero.git",
|
|
48
|
+
"directory": "packages/agent"
|
|
49
|
+
},
|
|
50
|
+
"bugs": "https://github.com/axxra/forgezero/issues",
|
|
51
|
+
"exports": {
|
|
52
|
+
"./provision": {
|
|
53
|
+
"types": "./dist/provision.d.ts",
|
|
54
|
+
"default": "./dist/provision.js"
|
|
55
|
+
},
|
|
56
|
+
"./subscribe": {
|
|
57
|
+
"types": "./dist/subscribe.d.ts",
|
|
58
|
+
"default": "./dist/subscribe.js"
|
|
59
|
+
},
|
|
60
|
+
"./pipeline": {
|
|
61
|
+
"types": "./dist/pipeline.d.ts",
|
|
62
|
+
"default": "./dist/pipeline.js"
|
|
63
|
+
},
|
|
64
|
+
"./ssh-listen": {
|
|
65
|
+
"types": "./dist/ssh-listen.d.ts",
|
|
66
|
+
"default": "./dist/ssh-listen.js"
|
|
67
|
+
},
|
|
68
|
+
"./ssh-server": {
|
|
69
|
+
"types": "./dist/ssh-server.d.ts",
|
|
70
|
+
"default": "./dist/ssh-server.js"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|