@zgeoff/atc 2.20.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +187 -118
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +270 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/store/state-store.ts +16 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -62,8 +62,10 @@ import { EffectRemainsError } from './effect-remains-error';
|
|
|
62
62
|
import { EventSignal } from './event-signal';
|
|
63
63
|
import { startHookServer } from './hooks';
|
|
64
64
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
65
|
+
import { isAllowedListenHost } from './is-allowed-listen-host';
|
|
65
66
|
import { isOwnHookEvent } from './is-own-hook-event';
|
|
66
67
|
import { isTreeInReach } from './is-tree-in-reach';
|
|
68
|
+
import { loadListenerTokens } from './load-listener-tokens';
|
|
67
69
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
68
70
|
import { makeHookRunner } from './make-hook-runner';
|
|
69
71
|
import type { HookScope } from './make-hook-runner';
|
|
@@ -82,6 +84,8 @@ import type { Session, SessionDescriptor } from './sessions';
|
|
|
82
84
|
import { startEventsServer } from './start-events-server';
|
|
83
85
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
84
86
|
import { startSessionBridge } from './start-session-bridge';
|
|
87
|
+
import { startTCPListener } from './start-tcp-listener';
|
|
88
|
+
import type { TCPListener } from './start-tcp-listener';
|
|
85
89
|
import { TapRegistry } from './tap-registry';
|
|
86
90
|
import type { TargetAccess } from './target-access';
|
|
87
91
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
@@ -151,6 +155,10 @@ export interface DaemonOptions {
|
|
|
151
155
|
// How long a confirm token from `session.forget` stays usable.
|
|
152
156
|
readonly forgetConfirmMs?: number;
|
|
153
157
|
|
|
158
|
+
// When set, a TCP listener serves the client protocol on this address to
|
|
159
|
+
// peers whose handshake presents a token from the token file.
|
|
160
|
+
readonly listen?: ListenOptions;
|
|
161
|
+
|
|
154
162
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
155
163
|
// entrypoint exits the process, tests leave it unset.
|
|
156
164
|
readonly onQuit?: () => void;
|
|
@@ -169,11 +177,35 @@ export interface DaemonOptions {
|
|
|
169
177
|
readonly gitTransports?: readonly string[] | InvalidGitTransports;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
// The TCP listener's address and the file holding the tokens a handshake
|
|
181
|
+
// may present.
|
|
182
|
+
interface ListenOptions {
|
|
183
|
+
readonly host: string;
|
|
184
|
+
readonly port: number;
|
|
185
|
+
readonly tokenFile: string;
|
|
186
|
+
|
|
187
|
+
// How long a handshake waits once its source address has failed five
|
|
188
|
+
// times within a minute; 10 s when unset.
|
|
189
|
+
readonly failureDelayMs?: number;
|
|
190
|
+
|
|
191
|
+
// How many delayed handshakes may wait at once across every address; 64
|
|
192
|
+
// when unset.
|
|
193
|
+
readonly maxDelayedHandshakes?: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
172
196
|
export interface DaemonHandle {
|
|
173
197
|
readonly stop: () => Promise<void>;
|
|
174
198
|
|
|
175
199
|
// How many client-protocol connections are open right now.
|
|
176
200
|
readonly countClients: () => number;
|
|
201
|
+
|
|
202
|
+
// The port the TCP listener bound, or null without one.
|
|
203
|
+
readonly listenPort: number | null;
|
|
204
|
+
|
|
205
|
+
// Reads the token file again and closes every TCP connection whose
|
|
206
|
+
// handshake token it no longer holds. A file that fails to load drops
|
|
207
|
+
// every token and closes every TCP connection until a load succeeds.
|
|
208
|
+
readonly refreshTokens: () => void;
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
// How long a started Claude session may go without a tap before a message to
|
|
@@ -191,6 +223,13 @@ const LOCAL_PRINCIPAL = 'local';
|
|
|
191
223
|
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
192
224
|
const IDEMPOTENCY_SWEEP_MS = 60 * 60 * 1000;
|
|
193
225
|
|
|
226
|
+
// How long a TCP handshake waits once its address has failed too often.
|
|
227
|
+
const HANDSHAKE_FAILURE_DELAY_MS = 10_000;
|
|
228
|
+
|
|
229
|
+
// How many delayed handshakes may wait at once; a handshake over the cap is
|
|
230
|
+
// refused at once.
|
|
231
|
+
const MAX_DELAYED_HANDSHAKES = 64;
|
|
232
|
+
|
|
194
233
|
// How long startup waits for a daemon that is shutting down to release the
|
|
195
234
|
// state lock before refusing to start.
|
|
196
235
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -207,6 +246,10 @@ const LOCK_WAIT_MS = 2000;
|
|
|
207
246
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
208
247
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
209
248
|
|
|
249
|
+
// The listener's address and tokens are checked before the daemon takes
|
|
250
|
+
// anything, so a refused listener leaves no state behind.
|
|
251
|
+
const listenTokens = opts.listen === undefined ? null : requireListenTokens(opts.listen);
|
|
252
|
+
|
|
210
253
|
// One daemon per state directory: the lock comes before the store, the
|
|
211
254
|
// sockets, or the fleet, so a second daemon touches none of them.
|
|
212
255
|
const stateDir = dirname(opts.dbPath);
|
|
@@ -1213,6 +1256,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1213
1256
|
const ctx: DaemonContext = {
|
|
1214
1257
|
build: opts.build,
|
|
1215
1258
|
daemonID: store.daemonID,
|
|
1259
|
+
idempotencyRetentionMs: IDEMPOTENCY_TTL_MS,
|
|
1216
1260
|
collectSessions: () => mgr.collectDescriptors(),
|
|
1217
1261
|
collectSpawnDirs: async (access) => {
|
|
1218
1262
|
const dirs = await store.collectSpawnDirs();
|
|
@@ -1243,6 +1287,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1243
1287
|
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
1244
1288
|
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
1245
1289
|
buildTargetAccess: (principal) => buildTargetAccess(principals, targetsByID, principal),
|
|
1290
|
+
hasListedPrincipal: (principal) => principals?.has(principal) ?? false,
|
|
1246
1291
|
findSessionGrant: (id) => {
|
|
1247
1292
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1248
1293
|
|
|
@@ -1903,6 +1948,76 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1903
1948
|
},
|
|
1904
1949
|
};
|
|
1905
1950
|
|
|
1951
|
+
// Releases everything the daemon holds except its listeners, on a stop
|
|
1952
|
+
// and on a start that fails once it holds the lock.
|
|
1953
|
+
const releaseResources = async () => {
|
|
1954
|
+
clearInterval(idempotencySweep);
|
|
1955
|
+
eventsServer?.stop();
|
|
1956
|
+
reporter.stop(true);
|
|
1957
|
+
mgr.detachAll();
|
|
1958
|
+
|
|
1959
|
+
for (const target of targets) {
|
|
1960
|
+
target.provider?.dispose();
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
for (const runtime of runtimes.values()) {
|
|
1964
|
+
runtime.dispose();
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
runtimes.clear();
|
|
1968
|
+
eventSignal.dispose();
|
|
1969
|
+
|
|
1970
|
+
await store.stop();
|
|
1971
|
+
|
|
1972
|
+
try {
|
|
1973
|
+
unlinkSync(recordPath);
|
|
1974
|
+
} catch {}
|
|
1975
|
+
|
|
1976
|
+
if (opts.pidPath !== undefined) {
|
|
1977
|
+
try {
|
|
1978
|
+
unlinkSync(opts.pidPath);
|
|
1979
|
+
} catch {}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
lock.dispose();
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a connection is a live object the daemon releases
|
|
1986
|
+
const detachConnection = (connection: DaemonConnection) => {
|
|
1987
|
+
clients.delete(connection);
|
|
1988
|
+
ctx.detachClient(connection);
|
|
1989
|
+
taps.detachAll(connection);
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
// The TCP listener binds before the unix socket, so a bind that fails
|
|
1993
|
+
// refuses the start before any client can connect, and releases what the
|
|
1994
|
+
// daemon holds, the lock included.
|
|
1995
|
+
let tcpListener: TCPListener | null = null;
|
|
1996
|
+
|
|
1997
|
+
if (opts.listen !== undefined && listenTokens !== null) {
|
|
1998
|
+
try {
|
|
1999
|
+
tcpListener = startTCPListener({
|
|
2000
|
+
host: opts.listen.host,
|
|
2001
|
+
port: opts.listen.port,
|
|
2002
|
+
tokens: listenTokens,
|
|
2003
|
+
failureDelayMs: opts.listen.failureDelayMs ?? HANDSHAKE_FAILURE_DELAY_MS,
|
|
2004
|
+
maxDelayedHandshakes: opts.listen.maxDelayedHandshakes ?? MAX_DELAYED_HANDSHAKES,
|
|
2005
|
+
openConnection: (socket, peer) => {
|
|
2006
|
+
const connection = new DaemonConnection(socket, ctx, peer);
|
|
2007
|
+
|
|
2008
|
+
clients.add(connection);
|
|
2009
|
+
|
|
2010
|
+
return connection;
|
|
2011
|
+
},
|
|
2012
|
+
closeConnection: detachConnection,
|
|
2013
|
+
});
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
await releaseResources();
|
|
2016
|
+
|
|
2017
|
+
throw buildBindRefusal(opts.listen, error);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1906
2021
|
try {
|
|
1907
2022
|
unlinkSync(opts.socketPath);
|
|
1908
2023
|
} catch {}
|
|
@@ -1922,52 +2037,43 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1922
2037
|
socket.data.drain();
|
|
1923
2038
|
},
|
|
1924
2039
|
close(socket) {
|
|
1925
|
-
|
|
1926
|
-
ctx.detachClient(socket.data);
|
|
1927
|
-
taps.detachAll(socket.data);
|
|
2040
|
+
detachConnection(socket.data);
|
|
1928
2041
|
},
|
|
1929
2042
|
error() {},
|
|
1930
2043
|
},
|
|
1931
2044
|
});
|
|
1932
2045
|
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
for (const client of clients) {
|
|
1937
|
-
client.dispose();
|
|
2046
|
+
const refreshTokens = () => {
|
|
2047
|
+
if (opts.listen === undefined || tcpListener === null) {
|
|
2048
|
+
return;
|
|
1938
2049
|
}
|
|
1939
2050
|
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
server.stop(true);
|
|
1943
|
-
eventsServer?.stop();
|
|
1944
|
-
reporter.stop(true);
|
|
1945
|
-
mgr.detachAll();
|
|
2051
|
+
const loaded = loadListenerTokens(opts.listen.tokenFile);
|
|
1946
2052
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
}
|
|
2053
|
+
if (loaded.ok) {
|
|
2054
|
+
tcpListener.setTokens(loaded.tokens);
|
|
1950
2055
|
|
|
1951
|
-
|
|
1952
|
-
runtime.dispose();
|
|
2056
|
+
return;
|
|
1953
2057
|
}
|
|
1954
2058
|
|
|
1955
|
-
|
|
1956
|
-
eventSignal.dispose();
|
|
2059
|
+
tcpListener.setTokens(null);
|
|
1957
2060
|
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
} catch {}
|
|
2061
|
+
mgr.log(
|
|
2062
|
+
`atc daemon: token reload failed (${loaded.reason}); every TCP connection is closed and refused until a reload succeeds`,
|
|
2063
|
+
);
|
|
2064
|
+
};
|
|
1963
2065
|
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2066
|
+
stopDaemon = async () => {
|
|
2067
|
+
// Ends each client itself so every peer sees the close: a stopped
|
|
2068
|
+
// listener does not reliably end the connections it already accepted.
|
|
2069
|
+
for (const client of clients) {
|
|
2070
|
+
client.dispose();
|
|
1968
2071
|
}
|
|
1969
2072
|
|
|
1970
|
-
|
|
2073
|
+
tcpListener?.stop();
|
|
2074
|
+
server.stop(true);
|
|
2075
|
+
|
|
2076
|
+
await releaseResources();
|
|
1971
2077
|
};
|
|
1972
2078
|
|
|
1973
2079
|
writeDaemonRecord(recordPath, {
|
|
@@ -1975,9 +2081,57 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1975
2081
|
socketPath: opts.socketPath,
|
|
1976
2082
|
reporterSocketPath: opts.reporterSocketPath,
|
|
1977
2083
|
eventsSocketPath: opts.eventsSocketPath ?? null,
|
|
2084
|
+
listenPort: tcpListener?.port ?? null,
|
|
1978
2085
|
});
|
|
1979
2086
|
|
|
1980
|
-
return {
|
|
2087
|
+
return {
|
|
2088
|
+
stop: stopDaemon,
|
|
2089
|
+
countClients: () => clients.size,
|
|
2090
|
+
listenPort: tcpListener?.port ?? null,
|
|
2091
|
+
refreshTokens,
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// The tokens the TCP listener starts with. Throws, with the code a refused
|
|
2096
|
+
// start carries, for an address outside the allowed ranges or a token file
|
|
2097
|
+
// that fails to load, so the daemon never starts with a listener that
|
|
2098
|
+
// takes no token or binds where it must not.
|
|
2099
|
+
function requireListenTokens(listen: ListenOptions): readonly string[] {
|
|
2100
|
+
if (!isAllowedListenHost(listen.host)) {
|
|
2101
|
+
throw Object.assign(
|
|
2102
|
+
new Error(
|
|
2103
|
+
`atc daemon: --listen refuses '${listen.host}': bind a loopback address or one in 100.64.0.0/10 or fd7a:115c:a1e4::/48`,
|
|
2104
|
+
),
|
|
2105
|
+
{ code: 'listen_refused' },
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const loaded = loadListenerTokens(listen.tokenFile);
|
|
2110
|
+
|
|
2111
|
+
if (!loaded.ok) {
|
|
2112
|
+
throw Object.assign(new Error(`atc daemon: --token-file: ${loaded.reason}`), {
|
|
2113
|
+
code: 'listen_refused',
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
return loaded.tokens;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// The refused start for a TCP listener whose bind failed, holding the
|
|
2121
|
+
// address and the bind error's code, such as EADDRINUSE for a port another
|
|
2122
|
+
// socket holds.
|
|
2123
|
+
function buildBindRefusal(listen: ListenOptions, error: unknown): Error {
|
|
2124
|
+
const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : null;
|
|
2125
|
+
|
|
2126
|
+
const address = listen.host.includes(':')
|
|
2127
|
+
? `[${listen.host}]:${listen.port}`
|
|
2128
|
+
: `${listen.host}:${listen.port}`;
|
|
2129
|
+
|
|
2130
|
+
const reason = typeof code === 'string' ? code : String(error);
|
|
2131
|
+
|
|
2132
|
+
return Object.assign(new Error(`atc daemon: --listen cannot bind ${address} (${reason})`), {
|
|
2133
|
+
code: 'listen_refused',
|
|
2134
|
+
});
|
|
1981
2135
|
}
|
|
1982
2136
|
|
|
1983
2137
|
// Tells a tap its subscription is over so the `atc tap` process behind it
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The fingerprint of the token the presented bearer token matches, or null
|
|
5
|
+
* when it matches none. The comparison runs on SHA-256 digests in constant
|
|
6
|
+
* time against every token, so neither a token's length nor which token a
|
|
7
|
+
* guess came close to shows in the timing. The fingerprint is the matched
|
|
8
|
+
* token's digest in hex, which identifies the token without holding it.
|
|
9
|
+
*/
|
|
10
|
+
export function findTokenFingerprint(tokens: readonly string[], presented: string): string | null {
|
|
11
|
+
const digest = createHash('sha256').update(presented).digest();
|
|
12
|
+
let matched: string | null = null;
|
|
13
|
+
|
|
14
|
+
for (const token of tokens) {
|
|
15
|
+
const candidate = createHash('sha256').update(token).digest();
|
|
16
|
+
|
|
17
|
+
if (timingSafeEqual(candidate, digest) && matched === null) {
|
|
18
|
+
matched = candidate.toString('hex');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return matched;
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// How far back failures count, and how many within that window delay the
|
|
2
|
+
// next handshake from the same address.
|
|
3
|
+
const FAILURE_WINDOW_MS = 60_000;
|
|
4
|
+
const FAILURE_LIMIT = 5;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Failed TCP handshakes per source address. Once an address has failed
|
|
8
|
+
* five times within a minute, its next handshake waits the configured
|
|
9
|
+
* delay before the daemon checks it, which slows a token guesser without
|
|
10
|
+
* locking out a peer that recovers.
|
|
11
|
+
*/
|
|
12
|
+
export class HandshakeThrottle {
|
|
13
|
+
private readonly delayMs: number;
|
|
14
|
+
|
|
15
|
+
private readonly failures = new Map<string, number[]>();
|
|
16
|
+
|
|
17
|
+
constructor(delayMs: number) {
|
|
18
|
+
this.delayMs = delayMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
recordFailure(address: string, now: number): void {
|
|
22
|
+
// Only the latest failures up to the limit decide a delay, so an
|
|
23
|
+
// address never holds more than that many, however often it fails.
|
|
24
|
+
const recent = [...this.collectRecent(address, now), now].slice(-FAILURE_LIMIT);
|
|
25
|
+
|
|
26
|
+
this.failures.set(address, recent);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long the next handshake from the address waits, in milliseconds.
|
|
31
|
+
*/
|
|
32
|
+
getDelay(address: string, now: number): number {
|
|
33
|
+
const recent = this.collectRecent(address, now);
|
|
34
|
+
|
|
35
|
+
if (recent.length === 0) {
|
|
36
|
+
this.failures.delete(address);
|
|
37
|
+
} else {
|
|
38
|
+
this.failures.set(address, recent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return recent.length >= FAILURE_LIMIT ? this.delayMs : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private collectRecent(address: string, now: number): number[] {
|
|
45
|
+
return (this.failures.get(address) ?? []).filter((at) => now - at < FAILURE_WINDOW_MS);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -3,12 +3,14 @@ import type { EffectTarget, IdempotencyRecord } from '../store/idempotency-recor
|
|
|
3
3
|
import type { StateStore } from '../store/state-store';
|
|
4
4
|
import { EffectRemainsError } from './effect-remains-error';
|
|
5
5
|
|
|
6
|
-
// A request's idempotency key, the hash of the payload it came with,
|
|
7
|
-
//
|
|
6
|
+
// A request's idempotency key, the hash of the payload it came with, the
|
|
7
|
+
// principal the request acts as when it is not the ledger's own, and
|
|
8
|
+
// whether it only replays a key the ledger already holds.
|
|
8
9
|
export interface KeyedRequest {
|
|
9
10
|
readonly key: string;
|
|
10
11
|
readonly payloadHash: string;
|
|
11
12
|
readonly principal?: string;
|
|
13
|
+
readonly replayOnly?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
interface IdempotentCall<T> {
|
|
@@ -45,6 +47,9 @@ interface IdempotentCall<T> {
|
|
|
45
47
|
* answer; one with a different payload is `idempotency_conflict`; one whose
|
|
46
48
|
* effect a stopped daemon may or may not have run is `outcome_unknown` with
|
|
47
49
|
* the effect id in `data.effectRef`, and never starts the effect again.
|
|
50
|
+
* A replay-only request never claims: it answers a held key as a retry
|
|
51
|
+
* does, and refuses a key the ledger does not hold, never held or since
|
|
52
|
+
* swept, with `idempotency_key_unknown`, running nothing.
|
|
48
53
|
* An effect that may still stand once its request fails is answered with
|
|
49
54
|
* `outcome_unknown` and its effect id even when recording that outcome
|
|
50
55
|
* fails: the claim stays held either way, and the failed write goes to the
|
|
@@ -107,6 +112,19 @@ export class IdempotencyLedger {
|
|
|
107
112
|
key: call.keyed.key,
|
|
108
113
|
};
|
|
109
114
|
|
|
115
|
+
if (call.keyed.replayOnly === true) {
|
|
116
|
+
const found = await this.store.findIdempotencyKey(id);
|
|
117
|
+
|
|
118
|
+
if (found === null) {
|
|
119
|
+
throw new DaemonError(
|
|
120
|
+
'idempotency_key_unknown',
|
|
121
|
+
`this daemon holds no ${call.operation} under idempotency key '${call.keyed.key}', so a replay-only request runs nothing`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return answerHeldKey(call, found);
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
const held = await this.store.claimIdempotencyKey({
|
|
111
129
|
...id,
|
|
112
130
|
payloadHash: call.keyed.payloadHash,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
2
|
+
|
|
3
|
+
// An IPv6 range as its leading 16-bit groups and how many of their bits
|
|
4
|
+
// must match.
|
|
5
|
+
interface IPv6Range {
|
|
6
|
+
readonly groups: readonly number[];
|
|
7
|
+
readonly bits: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The IPv6 ranges a listener may bind in: loopback `::1/128` and the
|
|
11
|
+
// tailnet range `fd7a:115c:a1e4::/48`.
|
|
12
|
+
const IPV6_RANGES: readonly IPv6Range[] = [
|
|
13
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
14
|
+
{ groups: [0xfd_7a, 0x11_5c, 0xa1_e4], bits: 48 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether a TCP listener may bind the host: an IP literal that is loopback
|
|
19
|
+
* (`127.0.0.0/8`, `::1`) or inside the tailnet ranges `100.64.0.0/10` and
|
|
20
|
+
* `fd7a:115c:a1e4::/48`. The protocol carries no TLS, so a listener relies
|
|
21
|
+
* on the tailnet's encryption, and a wildcard bind or a host name, whose
|
|
22
|
+
* address could resolve anywhere, is refused.
|
|
23
|
+
*/
|
|
24
|
+
export function isAllowedListenHost(host: string): boolean {
|
|
25
|
+
if (isIPv4(host)) {
|
|
26
|
+
const octets = host.split('.').map(Number);
|
|
27
|
+
const first = octets[0] ?? -1;
|
|
28
|
+
const second = octets[1] ?? -1;
|
|
29
|
+
|
|
30
|
+
return first === 127 || (first === 100 && second >= 64 && second <= 127);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isIPv6(host)) {
|
|
34
|
+
const groups = expandIPv6Groups(host);
|
|
35
|
+
|
|
36
|
+
return groups !== null && IPV6_RANGES.some((range) => hasPrefix(groups, range));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The eight 16-bit groups of an IPv6 literal, or null for a literal with an
|
|
43
|
+
// embedded IPv4 tail, which no allowed range holds.
|
|
44
|
+
function expandIPv6Groups(host: string): number[] | null {
|
|
45
|
+
if (host.includes('.')) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [head = '', tail] = host.split('::');
|
|
50
|
+
const headGroups = head === '' ? [] : head.split(':');
|
|
51
|
+
const tailGroups = tail === undefined || tail === '' ? [] : tail.split(':');
|
|
52
|
+
const fill = 8 - headGroups.length - tailGroups.length;
|
|
53
|
+
|
|
54
|
+
return [...headGroups, ...Array.from({ length: fill }, () => '0'), ...tailGroups].map((group) =>
|
|
55
|
+
Number.parseInt(group, 16),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasPrefix(groups: readonly number[], range: IPv6Range): boolean {
|
|
60
|
+
const whole = Math.floor(range.bits / 16);
|
|
61
|
+
|
|
62
|
+
return range.groups.slice(0, whole).every((group, index) => groups[index] === group);
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// The fewest bytes a listener token may hold.
|
|
4
|
+
const MIN_TOKEN_BYTES = 32;
|
|
5
|
+
|
|
6
|
+
type LoadedListenerTokens =
|
|
7
|
+
| { readonly ok: true; readonly tokens: readonly string[] }
|
|
8
|
+
| { readonly ok: false; readonly reason: string };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the TCP listener's token file: one or two tokens, one per line,
|
|
12
|
+
* each at least 32 bytes once surrounding whitespace is trimmed. A final
|
|
13
|
+
* newline is allowed. An unreadable file, an empty one, a blank line, a
|
|
14
|
+
* third token, or a short token makes the whole file invalid, so a broken
|
|
15
|
+
* file never leaves part of it working.
|
|
16
|
+
*/
|
|
17
|
+
export function loadListenerTokens(path: string): LoadedListenerTokens {
|
|
18
|
+
let text: string;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
text = readFileSync(path, 'utf8');
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
24
|
+
|
|
25
|
+
return { ok: false, reason: `cannot read ${path}: ${detail}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lines = (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n');
|
|
29
|
+
const tokens = lines.map((line) => line.trim());
|
|
30
|
+
|
|
31
|
+
if (tokens.some((token) => Buffer.byteLength(token) < MIN_TOKEN_BYTES)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: `${path} holds a token under ${MIN_TOKEN_BYTES} bytes or a blank line`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (tokens.length > 2) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: `${path} holds ${tokens.length} lines; it takes one or two tokens`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ok: true, tokens };
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isAllowedListenHost } from './is-allowed-listen-host';
|
|
2
|
+
|
|
3
|
+
type ParsedListenAddress =
|
|
4
|
+
| { readonly ok: true; readonly host: string; readonly port: number }
|
|
5
|
+
| { readonly ok: false; readonly message: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses a `--listen` value: `<host>:<port>`, with an IPv6 host in
|
|
9
|
+
* brackets (`[::1]:8415`). The host must be an address a listener may bind
|
|
10
|
+
* (loopback or a tailnet range) and the port a decimal from 0 to 65535,
|
|
11
|
+
* where 0 lets the kernel pick a free port.
|
|
12
|
+
*/
|
|
13
|
+
export function parseListenAddress(raw: string): ParsedListenAddress {
|
|
14
|
+
const match = /^(?:\[(?<v6>[^\]]+)\]|(?<v4>[^:[\]]+)):(?<port>\d{1,5})$/.exec(raw);
|
|
15
|
+
const host = match?.groups?.['v6'] ?? match?.groups?.['v4'];
|
|
16
|
+
const port = Number(match?.groups?.['port']);
|
|
17
|
+
|
|
18
|
+
if (host === undefined || !Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: `--listen takes <host>:<port> with a port from 0 to 65535, not '${raw}'`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!isAllowedListenHost(host)) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
message: `--listen refuses '${host}': bind a loopback address or one in 100.64.0.0/10 or fd7a:115c:a1e4::/48`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { ok: true, host, port };
|
|
33
|
+
}
|