@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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +5 -0
  3. package/src/agents/gateway-adapter.ts +25 -2
  4. package/src/cli.ts +187 -113
  5. package/src/client/collect-agent-picks.ts +5 -2
  6. package/src/client/daemon-client.ts +29 -18
  7. package/src/daemon/build-agent-list.ts +8 -6
  8. package/src/daemon/build-auth-binding.ts +58 -0
  9. package/src/daemon/build-imp-name.ts +11 -0
  10. package/src/daemon/build-imp-provider.ts +25 -2
  11. package/src/daemon/build-payload-hash.ts +4 -3
  12. package/src/daemon/daemon-connection.ts +277 -20
  13. package/src/daemon/daemon-context.ts +8 -0
  14. package/src/daemon/daemon.ts +186 -32
  15. package/src/daemon/find-token-fingerprint.ts +23 -0
  16. package/src/daemon/handshake-throttle.ts +47 -0
  17. package/src/daemon/idempotency-ledger.ts +20 -2
  18. package/src/daemon/imp-port.ts +4 -2
  19. package/src/daemon/imp-provider.ts +24 -19
  20. package/src/daemon/is-allowed-listen-host.ts +63 -0
  21. package/src/daemon/load-listener-tokens.ts +46 -0
  22. package/src/daemon/parse-listen-address.ts +33 -0
  23. package/src/daemon/restore-fleet.ts +2 -1
  24. package/src/daemon/sessions.ts +6 -0
  25. package/src/daemon/start-tcp-listener.ts +165 -0
  26. package/src/federation/build-binding-payload-hash.ts +34 -0
  27. package/src/federation/build-daemon-outdated-error.ts +14 -0
  28. package/src/federation/build-events-filter-hash.ts +16 -0
  29. package/src/federation/build-gateway-error.ts +45 -0
  30. package/src/federation/build-gateway-id.ts +14 -0
  31. package/src/federation/build-gateway-result.ts +31 -0
  32. package/src/federation/build-ruled-value.ts +53 -0
  33. package/src/federation/collect-unruled-id-paths.ts +46 -0
  34. package/src/federation/daemon-caller.ts +473 -0
  35. package/src/federation/daemon-pool.ts +56 -0
  36. package/src/federation/decode-gateway-cursor.ts +73 -0
  37. package/src/federation/encode-gateway-cursor.ts +15 -0
  38. package/src/federation/gateway-error.ts +25 -0
  39. package/src/federation/gateway-store.ts +253 -0
  40. package/src/federation/id-rules.ts +85 -0
  41. package/src/federation/load-gateway-registry.ts +29 -0
  42. package/src/federation/max-events-cursor-bytes.ts +4 -0
  43. package/src/federation/max-registry-daemons.ts +26 -0
  44. package/src/federation/merge-event-pages.ts +228 -0
  45. package/src/federation/open-gateway-caller.ts +55 -0
  46. package/src/federation/parse-gateway-id.ts +28 -0
  47. package/src/federation/parse-gateway-registry.ts +123 -0
  48. package/src/federation/pick-daemon-state.ts +46 -0
  49. package/src/federation/plan-event-reads.ts +54 -0
  50. package/src/federation/read-fleet-events.ts +279 -0
  51. package/src/federation/require-serving-daemon.ts +27 -0
  52. package/src/federation/resolve-daemon-request.ts +59 -0
  53. package/src/federation/routing-caller.ts +450 -0
  54. package/src/federation/types.ts +33 -0
  55. package/src/federation/wait-for-outcome.ts +38 -0
  56. package/src/mcp/answer-rpc-request.ts +14 -1
  57. package/src/mcp/build-tool-list.ts +6 -5
  58. package/src/mcp/mcp-tools.ts +41 -8
  59. package/src/mcp/require-daemon-features.ts +2 -0
  60. package/src/mcp/run-tool.ts +15 -1
  61. package/src/mcp/start-mcp-http-server.ts +53 -9
  62. package/src/mcp/types.ts +8 -1
  63. package/src/protocol/daemon-features.ts +9 -0
  64. package/src/protocol/protocol.ts +2 -0
  65. package/src/protocol/request-param-schemas.ts +6 -0
  66. package/src/run-daemon-id.ts +52 -0
  67. package/src/shared/collect-auth-profiles.ts +122 -0
  68. package/src/shared/collect-gateways.ts +223 -6
  69. package/src/shared/config.ts +32 -4
  70. package/src/shared/find-daemon-record.ts +8 -3
  71. package/src/shared/resolve-auth-profiles.ts +186 -0
  72. package/src/store/run-migrations.ts +77 -0
  73. package/src/store/runtime-auth-binding.ts +110 -0
  74. package/src/store/state-store.ts +202 -0
@@ -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
- clients.delete(socket.data);
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
- stopDaemon = async () => {
1934
- // Ends each client itself so every peer sees the close: a stopped
1935
- // listener does not reliably end the connections it already accepted.
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
- clearInterval(idempotencySweep);
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
- for (const target of targets) {
1948
- target.provider?.dispose();
1949
- }
2053
+ if (loaded.ok) {
2054
+ tcpListener.setTokens(loaded.tokens);
1950
2055
 
1951
- for (const runtime of runtimes.values()) {
1952
- runtime.dispose();
2056
+ return;
1953
2057
  }
1954
2058
 
1955
- runtimes.clear();
1956
- eventSignal.dispose();
2059
+ tcpListener.setTokens(null);
1957
2060
 
1958
- await store.stop();
1959
-
1960
- try {
1961
- unlinkSync(recordPath);
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
- if (opts.pidPath !== undefined) {
1965
- try {
1966
- unlinkSync(opts.pidPath);
1967
- } catch {}
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
- lock.dispose();
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 { stop: stopDaemon, countClients: () => clients.size };
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, and
7
- // the principal the request acts as when it is not the ledger's own.
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,
@@ -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; granting one it already holds changes
23
- // nothing, and nothing in the result distinguishes the two.
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, and the path of an atc binary already installed in the
24
- * image. None holds a credential.
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 = buildImpName(request.host);
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: buildImpName(spec.host),
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: buildImpName(hostKey),
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 = buildImpName(hostKey);
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(buildImpName(hostKey), command);
424
+ return await this.port.runCommand(this.getImpName(hostKey), command);
409
425
  } catch (error) {
410
- throw toHostRefusal(error, buildImpName(hostKey));
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
+ }