@zgeoff/atc 2.24.0 → 2.26.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 +27 -2
- package/src/agents/gateway-adapter.ts +272 -25
- package/src/client/collect-agent-picks.ts +2 -5
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +66 -7
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +84 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/format-log-field.ts +38 -0
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/make-non-blocking-log.ts +91 -0
- package/src/daemon/refusal-log.ts +163 -0
- package/src/daemon/restore-fleet.ts +16 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/start-tcp-listener.ts +47 -1
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +17 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -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 type { BrokerAuthHost } from './broker-auth-host';
|
|
4
5
|
import { buildImpName } from './build-imp-name';
|
|
5
6
|
import { buildTarArchive } from './build-tar-archive';
|
|
6
7
|
import type {
|
|
@@ -96,6 +97,8 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
96
97
|
// namespace an impd token's imp patterns are checked against.
|
|
97
98
|
readonly impPrefix: string;
|
|
98
99
|
|
|
100
|
+
readonly brokerAuth: BrokerAuthHost;
|
|
101
|
+
|
|
99
102
|
private readonly port: ImpPort;
|
|
100
103
|
|
|
101
104
|
private readonly target: ImpTargetOptions;
|
|
@@ -106,6 +109,14 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
106
109
|
|
|
107
110
|
private readonly hosts = new Map<string, ImpHost>();
|
|
108
111
|
|
|
112
|
+
// Each imp's readyings, sleeps, and lease returns run one after another,
|
|
113
|
+
// in the order they were asked for, so none of them gives back the lease
|
|
114
|
+
// a readying just took.
|
|
115
|
+
private readonly turns = new Map<string, Promise<void>>();
|
|
116
|
+
|
|
117
|
+
// The readyings of each imp still waiting for their turn.
|
|
118
|
+
private readonly waitingPrepares = new Map<string, number>();
|
|
119
|
+
|
|
109
120
|
private readonly atcBinary: string | null;
|
|
110
121
|
|
|
111
122
|
// Whether impd carries output offsets, once a prepare has read its
|
|
@@ -132,6 +143,19 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
132
143
|
dir,
|
|
133
144
|
atc: target.guestATC ?? (this.atcBinary === null ? null : `${dir}/bin/atc`),
|
|
134
145
|
};
|
|
146
|
+
|
|
147
|
+
this.brokerAuth = {
|
|
148
|
+
impPrefix: this.impPrefix,
|
|
149
|
+
port,
|
|
150
|
+
getImpName: (hostKey) => this.getImpName(hostKey),
|
|
151
|
+
createImp: (hostKey) =>
|
|
152
|
+
port.createImp({
|
|
153
|
+
name: this.getImpName(hostKey),
|
|
154
|
+
...(target.image === undefined ? {} : { image: target.image }),
|
|
155
|
+
...(target.memoryMib === undefined ? {} : { memoryMib: target.memoryMib }),
|
|
156
|
+
}),
|
|
157
|
+
destroyImp: (hostKey) => this.destroyHost(hostKey),
|
|
158
|
+
};
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
// The imp a host key runs on, under this provider's prefix.
|
|
@@ -141,7 +165,25 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
141
165
|
|
|
142
166
|
// Creates the host's imp when impd holds none, then takes the daemon's
|
|
143
167
|
// lease, which boots or wakes the imp.
|
|
144
|
-
readonly prepareHost =
|
|
168
|
+
readonly prepareHost = (request: HostRequest): Promise<void> => {
|
|
169
|
+
const name = this.getImpName(request.host);
|
|
170
|
+
|
|
171
|
+
this.waitingPrepares.set(name, (this.waitingPrepares.get(name) ?? 0) + 1);
|
|
172
|
+
|
|
173
|
+
return this.withHostTurn(name, () => {
|
|
174
|
+
const left = (this.waitingPrepares.get(name) ?? 1) - 1;
|
|
175
|
+
|
|
176
|
+
if (left === 0) {
|
|
177
|
+
this.waitingPrepares.delete(name);
|
|
178
|
+
} else {
|
|
179
|
+
this.waitingPrepares.set(name, left);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return this.prepareHostInTurn(request);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
private readonly prepareHostInTurn = async (request: HostRequest): Promise<void> => {
|
|
145
187
|
const name = this.getImpName(request.host);
|
|
146
188
|
const label = `atc-${request.daemonID}`;
|
|
147
189
|
|
|
@@ -187,9 +229,11 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
187
229
|
harnesses: 0,
|
|
188
230
|
renewTimer: null,
|
|
189
231
|
suspending: false,
|
|
232
|
+
isIdle: null,
|
|
190
233
|
};
|
|
191
234
|
|
|
192
235
|
host.suspending = false;
|
|
236
|
+
host.isIdle = request.isIdle ?? null;
|
|
193
237
|
|
|
194
238
|
this.hosts.set(request.host, host);
|
|
195
239
|
this.startRenewal(host, label);
|
|
@@ -201,6 +245,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
201
245
|
harnesses: 0,
|
|
202
246
|
renewTimer: null,
|
|
203
247
|
suspending: false,
|
|
248
|
+
isIdle: null,
|
|
204
249
|
};
|
|
205
250
|
|
|
206
251
|
this.hosts.set(spec.host, host);
|
|
@@ -225,11 +270,13 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
225
270
|
cwd: spec.cwd,
|
|
226
271
|
cols: spec.cols,
|
|
227
272
|
rows: spec.rows,
|
|
273
|
+
...(spec.requireBroker === true ? { require: ['broker'] } : {}),
|
|
228
274
|
},
|
|
229
275
|
{
|
|
230
276
|
offsets: this.offsets,
|
|
231
277
|
reconnectDelaysMs: this.reconnectDelaysMs,
|
|
232
278
|
...(reporter === null ? {} : { ready: reporter.forward.listening }),
|
|
279
|
+
...(spec.admit === undefined ? {} : { admit: spec.admit }),
|
|
233
280
|
isSuspending: () => host.suspending,
|
|
234
281
|
onDone: () => {
|
|
235
282
|
reporter?.forward.stop();
|
|
@@ -271,37 +318,57 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
271
318
|
// then asks impd to sleep the imp without force. Another owner's lease
|
|
272
319
|
// refuses the sleep: the daemon takes its lease back and the refusal
|
|
273
320
|
// carries what impd showed of the other leases.
|
|
274
|
-
readonly suspendHost =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
readonly suspendHost = (hostKey: string, isIdle?: () => boolean): Promise<void> =>
|
|
322
|
+
this.withHostTurn(this.getImpName(hostKey), async () => {
|
|
323
|
+
const host = this.hosts.get(hostKey) ?? {
|
|
324
|
+
name: this.getImpName(hostKey),
|
|
325
|
+
harnesses: 0,
|
|
326
|
+
renewTimer: null,
|
|
327
|
+
suspending: false,
|
|
328
|
+
isIdle: null,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
this.hosts.set(hostKey, host);
|
|
332
|
+
|
|
333
|
+
// A host a harness runs on, or that a readying waits for, is busy.
|
|
334
|
+
const isBusy = () =>
|
|
335
|
+
isIdle !== undefined &&
|
|
336
|
+
(!isIdle() || host.harnesses > 0 || (this.waitingPrepares.get(host.name) ?? 0) > 0);
|
|
337
|
+
|
|
338
|
+
if (isBusy()) {
|
|
339
|
+
throw buildBusyRefusal(host.name);
|
|
340
|
+
}
|
|
281
341
|
|
|
282
|
-
|
|
283
|
-
this.stopRenewal(host);
|
|
342
|
+
this.stopRenewal(host);
|
|
284
343
|
|
|
285
|
-
|
|
344
|
+
host.suspending = true;
|
|
286
345
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
346
|
+
try {
|
|
347
|
+
if (this.label !== null) {
|
|
348
|
+
await this.port.releaseLease(host.name, this.label);
|
|
349
|
+
}
|
|
291
350
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
host.suspending = false;
|
|
351
|
+
if (isBusy()) {
|
|
352
|
+
host.suspending = false;
|
|
295
353
|
|
|
296
|
-
|
|
297
|
-
await this.restoreLease(host);
|
|
354
|
+
await this.restoreLease(host);
|
|
298
355
|
|
|
299
|
-
|
|
300
|
-
|
|
356
|
+
throw buildBusyRefusal(host.name);
|
|
357
|
+
}
|
|
301
358
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
359
|
+
await this.port.suspendImp(host.name);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
host.suspending = false;
|
|
362
|
+
|
|
363
|
+
if (error instanceof ImpPortError && error.code === 'LEASED') {
|
|
364
|
+
await this.restoreLease(host);
|
|
365
|
+
|
|
366
|
+
throw buildLeasedRefusal(host.name, error.data);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
throw toHostRefusal(error, host.name);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
305
372
|
|
|
306
373
|
// A destroy ends every lease on the imp with it.
|
|
307
374
|
readonly destroyHost = async (hostKey: string): Promise<void> => {
|
|
@@ -437,13 +504,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
437
504
|
|
|
438
505
|
host.renewTimer = setInterval(
|
|
439
506
|
() => {
|
|
440
|
-
|
|
441
|
-
void this.tryReleaseLease(host);
|
|
442
|
-
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
void this.tryRenewLease(host, label);
|
|
507
|
+
void this.refreshLease(host, label);
|
|
447
508
|
},
|
|
448
509
|
(this.leaseSeconds * 1000) / 3,
|
|
449
510
|
);
|
|
@@ -473,6 +534,28 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
473
534
|
}
|
|
474
535
|
}
|
|
475
536
|
|
|
537
|
+
// Runs a readying, a sleep, or a lease return of an imp after the one
|
|
538
|
+
// before it there. A turn never starts in its caller's tick, so a harness
|
|
539
|
+
// end the daemon still applies counts before the turn checks the host.
|
|
540
|
+
private async withHostTurn<T>(name: string, run: () => Promise<T>): Promise<T> {
|
|
541
|
+
const before = this.turns.get(name) ?? Promise.resolve();
|
|
542
|
+
const turn = Promise.withResolvers<void>();
|
|
543
|
+
|
|
544
|
+
this.turns.set(name, turn.promise);
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
await before;
|
|
548
|
+
|
|
549
|
+
return await run();
|
|
550
|
+
} finally {
|
|
551
|
+
turn.resolve();
|
|
552
|
+
|
|
553
|
+
if (this.turns.get(name) === turn.promise) {
|
|
554
|
+
this.turns.delete(name);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
476
559
|
private async restoreLease(host: ImpHost): Promise<void> {
|
|
477
560
|
if (this.label === null) {
|
|
478
561
|
return;
|
|
@@ -487,19 +570,45 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
487
570
|
this.startRenewal(host, this.label);
|
|
488
571
|
}
|
|
489
572
|
|
|
490
|
-
|
|
491
|
-
|
|
573
|
+
// A host with no harness gives its lease back, unless a launch readies it,
|
|
574
|
+
// which keeps the lease renewed.
|
|
575
|
+
private async refreshLease(host: ImpHost, label: string): Promise<void> {
|
|
576
|
+
if (host.harnesses === 0) {
|
|
577
|
+
const released = await this.tryReleaseLease(host);
|
|
492
578
|
|
|
493
|
-
|
|
494
|
-
|
|
579
|
+
if (released) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
495
582
|
}
|
|
496
583
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
} catch {
|
|
500
|
-
return false;
|
|
584
|
+
if (host.renewTimer !== null) {
|
|
585
|
+
await this.tryRenewLease(host, label);
|
|
501
586
|
}
|
|
502
587
|
}
|
|
588
|
+
|
|
589
|
+
// Gives an imp's lease back once nothing runs or starts there, in the
|
|
590
|
+
// imp's turn, so it never drops a lease a readying took for a launch.
|
|
591
|
+
private tryReleaseLease(host: ImpHost): Promise<boolean> {
|
|
592
|
+
return this.withHostTurn(host.name, async () => {
|
|
593
|
+
const isBusy =
|
|
594
|
+
host.harnesses > 0 ||
|
|
595
|
+
host.suspending ||
|
|
596
|
+
(this.waitingPrepares.get(host.name) ?? 0) > 0 ||
|
|
597
|
+
host.isIdle?.() === false;
|
|
598
|
+
|
|
599
|
+
if (isBusy || this.label === null) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.stopRenewal(host);
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
return await this.port.releaseLease(host.name, this.label);
|
|
607
|
+
} catch {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
503
612
|
}
|
|
504
613
|
|
|
505
614
|
// What the provider follows of one imp: its name, how many harnesses run
|
|
@@ -510,6 +619,10 @@ interface ImpHost {
|
|
|
510
619
|
harnesses: number;
|
|
511
620
|
renewTimer: ReturnType<typeof setInterval> | null;
|
|
512
621
|
suspending: boolean;
|
|
622
|
+
|
|
623
|
+
// The daemon's check that nothing runs or starts on the host, from its
|
|
624
|
+
// latest readying; null until one gives it.
|
|
625
|
+
isIdle: (() => boolean) | null;
|
|
513
626
|
}
|
|
514
627
|
|
|
515
628
|
function buildImpSessionName(sessionID: string): string {
|
|
@@ -571,6 +684,14 @@ function buildGuestEnv(env: Readonly<Record<string, string>>): Record<string, st
|
|
|
571
684
|
return { TERM: 'xterm-256color', LANG: 'C.UTF-8', PATH: GUEST_PATH, ...own };
|
|
572
685
|
}
|
|
573
686
|
|
|
687
|
+
function buildBusyRefusal(name: string): DaemonError {
|
|
688
|
+
return new DaemonError(
|
|
689
|
+
'host_unavailable',
|
|
690
|
+
`imp ${name} stays awake: a session runs there or is starting there`,
|
|
691
|
+
{ provider: 'imp', problem: 'busy' },
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
574
695
|
function buildLeasedRefusal(name: string, data: unknown): DaemonError {
|
|
575
696
|
const record = typeof data === 'object' && data !== null ? data : {};
|
|
576
697
|
const leases = 'leases' in record && Array.isArray(record.leases) ? record.leases : [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { spawn } from 'bun-pty';
|
|
3
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
4
|
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
4
5
|
import type {
|
|
5
6
|
CommandResult,
|
|
@@ -39,7 +40,17 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
39
40
|
|
|
40
41
|
// The daemon's machine keeps no process the daemon lets go of, so a
|
|
41
42
|
// detach ends the harness as a kill does, after dropping every listener.
|
|
43
|
+
// It has no credential broker, so a harness that requires one never
|
|
44
|
+
// starts here.
|
|
42
45
|
readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
|
|
46
|
+
if (spec.requireBroker === true) {
|
|
47
|
+
throw new DaemonError(
|
|
48
|
+
'auth_target_unsupported',
|
|
49
|
+
"the daemon's own machine has no credential broker to start the harness behind",
|
|
50
|
+
{ provider: 'local-pty' },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
const pty = spawn(spec.bin, [...spec.args], {
|
|
44
55
|
name: 'xterm-256color',
|
|
45
56
|
cols: spec.cols,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { write } from 'node:fs';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
// The most bytes of lines that wait behind the write in flight; a line past
|
|
5
|
+
// it is dropped and counted.
|
|
6
|
+
const MAX_QUEUED_BYTES = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
// The node:fs write without a callback, which Bun runs on its thread pool.
|
|
9
|
+
const writeAsync = promisify(write);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Makes a log that writes each line to a file descriptor without ever
|
|
13
|
+
* blocking the event loop, so a reader that stops reading stalls nothing
|
|
14
|
+
* but the log. Each write goes through the asynchronous node:fs write,
|
|
15
|
+
* which Bun runs on its thread pool, one write at a time. Lines that
|
|
16
|
+
* arrive meanwhile queue up to 64 KiB; a line past that is dropped, and the
|
|
17
|
+
* next write after the drops starts with one `atc log dropped=N` line. A
|
|
18
|
+
* failed write drops its lines and counts them the same way, and the next
|
|
19
|
+
* line logged writes again.
|
|
20
|
+
*/
|
|
21
|
+
export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
22
|
+
let queue: string[] = [];
|
|
23
|
+
let queuedBytes = 0;
|
|
24
|
+
let dropped = 0;
|
|
25
|
+
let writing = false;
|
|
26
|
+
|
|
27
|
+
const recordLine = (line: string): boolean => {
|
|
28
|
+
const text = `${line}\n`;
|
|
29
|
+
const bytes = Buffer.byteLength(text);
|
|
30
|
+
|
|
31
|
+
if (queuedBytes + bytes > MAX_QUEUED_BYTES) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
queue.push(text);
|
|
36
|
+
|
|
37
|
+
queuedBytes += bytes;
|
|
38
|
+
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const writeNext = (): void => {
|
|
43
|
+
if (queue.length === 0 && dropped === 0) {
|
|
44
|
+
writing = false;
|
|
45
|
+
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
writing = true;
|
|
50
|
+
|
|
51
|
+
const lines = dropped > 0 ? [`atc log dropped=${String(dropped)}\n`, ...queue] : queue;
|
|
52
|
+
|
|
53
|
+
// The lines this write stands for, the ones the dropped line counts
|
|
54
|
+
// included, which a failed write drops again.
|
|
55
|
+
const count = dropped + queue.length;
|
|
56
|
+
|
|
57
|
+
queue = [];
|
|
58
|
+
queuedBytes = 0;
|
|
59
|
+
dropped = 0;
|
|
60
|
+
void writeChunk(Buffer.from(lines.join('')), count);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const writeChunk = async (chunk: Buffer, lineCount: number): Promise<void> => {
|
|
64
|
+
let rest = chunk;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
while (rest.length > 0) {
|
|
68
|
+
const result = await writeAsync(fd, rest);
|
|
69
|
+
|
|
70
|
+
rest = rest.subarray(result.bytesWritten);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
dropped += lineCount;
|
|
74
|
+
writing = false;
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
writeNext();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (line) => {
|
|
83
|
+
if (!recordLine(line)) {
|
|
84
|
+
dropped++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!writing) {
|
|
88
|
+
writeNext();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { formatLogField } from './format-log-field';
|
|
2
|
+
|
|
3
|
+
// Why the TCP listener refused a handshake: a missing token, a wrong one, a
|
|
4
|
+
// handshake that would wait while the cap of delayed handshakes is full, a
|
|
5
|
+
// socket that closed while its handshake waited, a line other than one
|
|
6
|
+
// handshake before the handshake passed, or a line over the size cap
|
|
7
|
+
// before it passed.
|
|
8
|
+
type HandshakeRefusalReason =
|
|
9
|
+
| 'missing_token'
|
|
10
|
+
| 'unauthorized'
|
|
11
|
+
| 'delay_cap_full'
|
|
12
|
+
| 'closed_during_delay'
|
|
13
|
+
| 'unexpected_line'
|
|
14
|
+
| 'line_too_long';
|
|
15
|
+
|
|
16
|
+
export type Refusal =
|
|
17
|
+
| {
|
|
18
|
+
readonly event: 'handshake_refused';
|
|
19
|
+
readonly peer: string;
|
|
20
|
+
readonly reason: HandshakeRefusalReason;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
readonly event: 'principal_refused';
|
|
24
|
+
readonly peer: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface RefusalLogOptions {
|
|
28
|
+
readonly log: (line: string) => void;
|
|
29
|
+
readonly now: () => number;
|
|
30
|
+
|
|
31
|
+
// How long a window of repeated refusals lasts before the next one is
|
|
32
|
+
// logged again.
|
|
33
|
+
readonly intervalMs: number;
|
|
34
|
+
|
|
35
|
+
// How many windows are tracked at once; a refusal that would start one
|
|
36
|
+
// past the cap counts toward one overflow window instead.
|
|
37
|
+
readonly maxWindows: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RefusalWindow {
|
|
41
|
+
readonly startedAt: number;
|
|
42
|
+
|
|
43
|
+
// The refusals in the window after the first, which no line holds yet.
|
|
44
|
+
pending: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The line of the window that counts refusals past the cap of windows.
|
|
48
|
+
const OVERFLOW_LINE = 'atc tcp event=refused peer=overflow';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Logs TCP listener refusals as `key=value` lines, at most one per window
|
|
52
|
+
* for each peer and kind of refusal: a handshake refusal per reason, and a
|
|
53
|
+
* principal refusal, which never holds the principal it gave. The first
|
|
54
|
+
* refusal of a window logs at once with `count=1`; later ones in the window
|
|
55
|
+
* are counted, and a line with their `count` follows once the window ends,
|
|
56
|
+
* so the counts of every line sum to every refusal. While the cap of
|
|
57
|
+
* windows is full, a refusal that would start a new window counts toward
|
|
58
|
+
* one overflow window with `peer=overflow` instead, so a flood across many
|
|
59
|
+
* peers logs no more lines than the cap allows. Ended windows are logged on
|
|
60
|
+
* the next refusal from any peer and on a drain.
|
|
61
|
+
*/
|
|
62
|
+
export class RefusalLog {
|
|
63
|
+
private readonly opts: RefusalLogOptions;
|
|
64
|
+
|
|
65
|
+
// Keyed by the line without its count, in the order the windows started.
|
|
66
|
+
private readonly windows = new Map<string, RefusalWindow>();
|
|
67
|
+
|
|
68
|
+
private overflow: RefusalWindow | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(opts: RefusalLogOptions) {
|
|
71
|
+
this.opts = opts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
record(refusal: Refusal): void {
|
|
75
|
+
const now = this.opts.now();
|
|
76
|
+
|
|
77
|
+
this.drainEnded(now);
|
|
78
|
+
|
|
79
|
+
const line = formatRefusal(refusal);
|
|
80
|
+
const window = this.windows.get(line);
|
|
81
|
+
|
|
82
|
+
if (window !== undefined) {
|
|
83
|
+
window.pending++;
|
|
84
|
+
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (this.windows.size >= this.opts.maxWindows) {
|
|
89
|
+
this.recordOverflow(now);
|
|
90
|
+
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.windows.set(line, { startedAt: now, pending: 0 });
|
|
95
|
+
this.opts.log(`${line} count=1`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Logs the pending count of every window and forgets them all.
|
|
100
|
+
*/
|
|
101
|
+
drain(): void {
|
|
102
|
+
for (const [line, window] of this.windows) {
|
|
103
|
+
this.logPending(line, window);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.windows.clear();
|
|
107
|
+
|
|
108
|
+
if (this.overflow !== null) {
|
|
109
|
+
this.logPending(OVERFLOW_LINE, this.overflow);
|
|
110
|
+
|
|
111
|
+
this.overflow = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Windows start in map order and all last the same interval, so the
|
|
116
|
+
// ended ones lead the map.
|
|
117
|
+
private drainEnded(now: number): void {
|
|
118
|
+
if (this.overflow !== null && this.hasEnded(this.overflow, now)) {
|
|
119
|
+
this.logPending(OVERFLOW_LINE, this.overflow);
|
|
120
|
+
|
|
121
|
+
this.overflow = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const [line, window] of this.windows) {
|
|
125
|
+
if (!this.hasEnded(window, now)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.windows.delete(line);
|
|
130
|
+
this.logPending(line, window);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private hasEnded(window: Readonly<RefusalWindow>, now: number): boolean {
|
|
135
|
+
return now - window.startedAt >= this.opts.intervalMs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private logPending(line: string, window: Readonly<RefusalWindow>): void {
|
|
139
|
+
if (window.pending > 0) {
|
|
140
|
+
this.opts.log(`${line} count=${window.pending}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private recordOverflow(now: number): void {
|
|
145
|
+
if (this.overflow !== null) {
|
|
146
|
+
this.overflow.pending++;
|
|
147
|
+
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.overflow = { startedAt: now, pending: 0 };
|
|
152
|
+
|
|
153
|
+
this.opts.log(`${OVERFLOW_LINE} count=1`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatRefusal(refusal: Refusal): string {
|
|
158
|
+
const peer = formatLogField(refusal.peer);
|
|
159
|
+
|
|
160
|
+
return refusal.event === 'handshake_refused'
|
|
161
|
+
? `atc tcp event=handshake_refused peer=${peer} reason=${refusal.reason}`
|
|
162
|
+
: `atc tcp event=principal_refused peer=${peer} principal=unlisted`;
|
|
163
|
+
}
|
|
@@ -185,7 +185,8 @@ function isSameSession(s: Session, entry: FleetEntry): boolean {
|
|
|
185
185
|
|
|
186
186
|
// The refusals that leave one session without a terminal: its target is
|
|
187
187
|
// misconfigured, gone from the config, changed, has no provider here, or
|
|
188
|
-
// cannot start a terminal, or its agent refuses every start
|
|
188
|
+
// cannot start a terminal, or its agent refuses every start, or its
|
|
189
|
+
// host's runtime auth binding refuses the launch, its broker included.
|
|
189
190
|
const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
|
|
190
191
|
'unsupported_operation',
|
|
191
192
|
'unknown_target',
|
|
@@ -195,6 +196,20 @@ const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
|
|
|
195
196
|
'host_unavailable',
|
|
196
197
|
'auth_not_configured',
|
|
197
198
|
'auth_target_unsupported',
|
|
199
|
+
'auth_impd_too_old',
|
|
200
|
+
'auth_token_scope',
|
|
201
|
+
'auth_token_too_broad',
|
|
202
|
+
'auth_imp_out_of_scope',
|
|
203
|
+
'auth_secret_not_grantable',
|
|
204
|
+
'auth_secret_mismatch',
|
|
205
|
+
'auth_runtime_mismatch',
|
|
206
|
+
'auth_grant_missing',
|
|
207
|
+
'auth_grants_mismatch',
|
|
208
|
+
'auth_rebind_required',
|
|
209
|
+
'auth_blocked',
|
|
210
|
+
'auth_binding_invalid',
|
|
211
|
+
'broker_not_ready',
|
|
212
|
+
'auth_placeholder_unsupported',
|
|
198
213
|
]);
|
|
199
214
|
|
|
200
215
|
async function tryAdoptTerminal(
|