@zgeoff/atc 2.19.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/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +187 -113
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +277 -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/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- 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/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -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 +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +202 -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,
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -19,8 +19,10 @@ export interface ImpPort {
|
|
|
19
19
|
// The names of the secrets granted to an imp.
|
|
20
20
|
readonly readGrants: (name: string) => Promise<readonly string[]>;
|
|
21
21
|
|
|
22
|
-
// Grants a secret to an imp
|
|
23
|
-
// nothing, and
|
|
22
|
+
// Grants a secret to an imp. impd's grant add is idempotent: granting one
|
|
23
|
+
// the imp already holds changes nothing, and impd returns no creation or
|
|
24
|
+
// ownership receipt, so nothing in the result tells a new grant from an
|
|
25
|
+
// existing one or shows who added it.
|
|
24
26
|
readonly createGrant: (name: string, secret: string) => Promise<void>;
|
|
25
27
|
|
|
26
28
|
// Revokes a secret from an imp, and reports whether impd held the grant.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import { LineDecoder } from '../protocol/line-decoder';
|
|
3
3
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
4
|
+
import { buildImpName } from './build-imp-name';
|
|
4
5
|
import { buildTarArchive } from './build-tar-archive';
|
|
5
6
|
import type {
|
|
6
7
|
CommandResult,
|
|
@@ -20,10 +21,12 @@ import { ImpPortError } from './imp-port-error';
|
|
|
20
21
|
/**
|
|
21
22
|
* The options an `imp` target takes beside its provider: the image a new
|
|
22
23
|
* imp boots, the memory it gets, the folder inside each imp that atc's
|
|
23
|
-
* files go under,
|
|
24
|
-
* image
|
|
24
|
+
* files go under, the path of an atc binary already installed in the
|
|
25
|
+
* image, and the prefix every imp name the target builds starts with.
|
|
26
|
+
* None holds a credential.
|
|
25
27
|
*/
|
|
26
28
|
export interface ImpTargetOptions {
|
|
29
|
+
readonly impPrefix?: string;
|
|
27
30
|
readonly image?: string;
|
|
28
31
|
readonly memoryMib?: number;
|
|
29
32
|
readonly guestDir?: string;
|
|
@@ -48,6 +51,9 @@ interface ImpProviderOptions {
|
|
|
48
51
|
// none.
|
|
49
52
|
const GUEST_DIR = '/tmp/atc';
|
|
50
53
|
|
|
54
|
+
// The start of every imp name when the target sets no prefix.
|
|
55
|
+
const IMP_PREFIX = 'atc-';
|
|
56
|
+
|
|
51
57
|
// impd takes a lease of 10 to 3600 seconds; the daemon renews at a third of it.
|
|
52
58
|
const LEASE_SECONDS = 600;
|
|
53
59
|
|
|
@@ -86,6 +92,10 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
86
92
|
|
|
87
93
|
readonly guest: GuestLayout;
|
|
88
94
|
|
|
95
|
+
// The literal start of every imp name this provider builds: the runtime
|
|
96
|
+
// namespace an impd token's imp patterns are checked against.
|
|
97
|
+
readonly impPrefix: string;
|
|
98
|
+
|
|
89
99
|
private readonly port: ImpPort;
|
|
90
100
|
|
|
91
101
|
private readonly target: ImpTargetOptions;
|
|
@@ -109,6 +119,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
109
119
|
constructor(port: ImpPort, target: ImpTargetOptions, options: ImpProviderOptions = {}) {
|
|
110
120
|
this.port = port;
|
|
111
121
|
this.target = target;
|
|
122
|
+
this.impPrefix = target.impPrefix ?? IMP_PREFIX;
|
|
112
123
|
this.leaseSeconds = options.leaseSeconds ?? LEASE_SECONDS;
|
|
113
124
|
this.reconnectDelaysMs = options.reconnectDelaysMs ?? RECONNECT_DELAYS_MS;
|
|
114
125
|
|
|
@@ -123,10 +134,15 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
123
134
|
};
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
// The imp a host key runs on, under this provider's prefix.
|
|
138
|
+
getImpName(hostKey: string): string {
|
|
139
|
+
return buildImpName(this.impPrefix, hostKey);
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
// Creates the host's imp when impd holds none, then takes the daemon's
|
|
127
143
|
// lease, which boots or wakes the imp.
|
|
128
144
|
readonly prepareHost = async (request: HostRequest): Promise<void> => {
|
|
129
|
-
const name =
|
|
145
|
+
const name = this.getImpName(request.host);
|
|
130
146
|
const label = `atc-${request.daemonID}`;
|
|
131
147
|
|
|
132
148
|
this.label = label;
|
|
@@ -181,7 +197,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
181
197
|
|
|
182
198
|
readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
|
|
183
199
|
const host = this.hosts.get(spec.host) ?? {
|
|
184
|
-
name:
|
|
200
|
+
name: this.getImpName(spec.host),
|
|
185
201
|
harnesses: 0,
|
|
186
202
|
renewTimer: null,
|
|
187
203
|
suspending: false,
|
|
@@ -257,7 +273,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
257
273
|
// carries what impd showed of the other leases.
|
|
258
274
|
readonly suspendHost = async (hostKey: string): Promise<void> => {
|
|
259
275
|
const host = this.hosts.get(hostKey) ?? {
|
|
260
|
-
name:
|
|
276
|
+
name: this.getImpName(hostKey),
|
|
261
277
|
harnesses: 0,
|
|
262
278
|
renewTimer: null,
|
|
263
279
|
suspending: false,
|
|
@@ -290,7 +306,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
290
306
|
// A destroy ends every lease on the imp with it.
|
|
291
307
|
readonly destroyHost = async (hostKey: string): Promise<void> => {
|
|
292
308
|
const host = this.hosts.get(hostKey);
|
|
293
|
-
const name =
|
|
309
|
+
const name = this.getImpName(hostKey);
|
|
294
310
|
|
|
295
311
|
if (host !== undefined) {
|
|
296
312
|
this.stopRenewal(host);
|
|
@@ -405,9 +421,9 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
405
421
|
}
|
|
406
422
|
|
|
407
423
|
try {
|
|
408
|
-
return await this.port.runCommand(
|
|
424
|
+
return await this.port.runCommand(this.getImpName(hostKey), command);
|
|
409
425
|
} catch (error) {
|
|
410
|
-
throw toHostRefusal(error,
|
|
426
|
+
throw toHostRefusal(error, this.getImpName(hostKey));
|
|
411
427
|
}
|
|
412
428
|
}
|
|
413
429
|
|
|
@@ -496,17 +512,6 @@ interface ImpHost {
|
|
|
496
512
|
suspending: boolean;
|
|
497
513
|
}
|
|
498
514
|
|
|
499
|
-
/**
|
|
500
|
-
* The imp a host key runs on: `atc-` and the first 20 letters and digits of
|
|
501
|
-
* the key, which is the atc session id of the session that owns the host.
|
|
502
|
-
*/
|
|
503
|
-
function buildImpName(hostKey: string): string {
|
|
504
|
-
return `atc-${hostKey
|
|
505
|
-
.toLowerCase()
|
|
506
|
-
.replaceAll(/[^a-z0-9]/g, '')
|
|
507
|
-
.slice(0, 20)}`;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
515
|
function buildImpSessionName(sessionID: string): string {
|
|
511
516
|
return `atc-${sessionID
|
|
512
517
|
.toLowerCase()
|
|
@@ -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
|
+
}
|