@ccmsg/cli 0.9.1 → 0.11.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.
@@ -9,7 +9,16 @@ import type { InstanceId } from "@ccmsg/protocol";
9
9
  * spelling is sized for. */
10
10
  const ID_BYTES = 16;
11
11
 
12
- const ID = /^[0-9a-f]{32}$/;
12
+ export const ID = /^[0-9a-f]{32}$/;
13
+
14
+ /** A fresh id of that width, for whatever is being named.
15
+ *
16
+ * One generator rather than one per kind of id: what an id has to be is
17
+ * unguessable-by-accident and the same width wherever it is read, and a second
18
+ * generator is a second answer to how wide that is. */
19
+ export function newId(): string {
20
+ return randomBytes(ID_BYTES).toString("hex");
21
+ }
13
22
 
14
23
  /** This instance's identity, read from the state directory and generated there
15
24
  * the first time it is asked for.
@@ -26,7 +35,7 @@ const ID = /^[0-9a-f]{32}$/;
26
35
  export function instanceIdentity(file: string): InstanceId {
27
36
  const held = read(file);
28
37
  if (held !== undefined) return held;
29
- const made = randomBytes(ID_BYTES).toString("hex");
38
+ const made = newId();
30
39
  mkdirSync(dirname(file), { recursive: true });
31
40
  writeFileSync(file, `${made}\n`);
32
41
  return made;
@@ -89,7 +89,14 @@ import {
89
89
  recordsDir,
90
90
  } from "../auth/index.ts";
91
91
  import { type Cidr, clientAddress, parseCidr } from "./client.ts";
92
- import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
92
+ import {
93
+ applied,
94
+ configOf,
95
+ DEFAULT_CONFIG,
96
+ type EntryConfig,
97
+ type InstanceConfig,
98
+ settle,
99
+ } from "./config.ts";
93
100
  import { completeHandlers } from "./handlers.ts";
94
101
  import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
95
102
  import { Log } from "./log.ts";
@@ -112,6 +119,15 @@ export interface StartOptions {
112
119
  readonly configHome?: string;
113
120
  /** Mirror the log to stderr. A foreground run wants it; a test does not. */
114
121
  readonly echoLog?: boolean;
122
+ /** Whether this start is the one that reads the edited files and writes down
123
+ * what checked out (§8.2).
124
+ *
125
+ * A supervisor does that for the instances it starts, so its children read
126
+ * what it applied and write nothing: one writer means no two processes
127
+ * racing over the same file, and it means `config diff --satisfied` compares
128
+ * against a value exactly one thing produced. A foreground start with no
129
+ * supervisor above it is the writer, because there is nobody else to be. */
130
+ readonly settle?: boolean;
115
131
  /** Overrides the confirmation poll of the sessions watch, for tests. */
116
132
  readonly pollMs?: number;
117
133
  /** Overrides the mesh's own intervals, for a test that cannot wait out a
@@ -176,7 +192,7 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
176
192
  try {
177
193
  // 3. the config. A broken one ends the start rather than turning the
178
194
  // setting it carried silently off (DV-Q9).
179
- const config = loadConfig(paths.configFile, paths.configHome);
195
+ const config = await configFor(paths, log, options.settle ?? true);
180
196
  // What the config says of the gateway, resolved before anything is built
181
197
  // from it: a webhook source whose secret cannot be read ends the start
182
198
  // here, for the same reason a broken config does (DV-Q9).
@@ -192,12 +208,12 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
192
208
  // `daemon run` on one the shared file does not list — gets its id now
193
209
  // rather than from an `add` that never happened (DR-0001 §2.1).
194
210
  const id = instanceIdentity(paths.instanceIdFile);
195
- // 5. the endpoint list, for an instance that has a mesh.
211
+ // 5. the mesh, for an instance the data names an address for.
196
212
  //
197
- // Which entry of it is this instance is settled by the probe, and the probe
198
- // has to arrive at a listener so the WebSocket is bound here and handed
199
- // to the instance. A list that names this instance no times, or twice, ends
200
- // the start.
213
+ // Which entry of the list is this instance is its own row, so nothing has
214
+ // to be asked of the network to settle it (§7.1). The WebSocket is still
215
+ // bound here and handed over, because the instance does not exist yet and
216
+ // a peer may dial the moment the address is up.
201
217
  const mesh = meshFor(id, config, log, options.meshTiming);
202
218
  const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
203
219
  // 6-8 are the instance's own construction and listen.
@@ -223,6 +239,35 @@ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
223
239
  }
224
240
  }
225
241
 
242
+ /** What this config home runs with: the settings that were read and checked.
243
+ *
244
+ * Either read from what was applied, or — on the start that has nobody above
245
+ * it — read from the edited files and written down if it holds. Both end at
246
+ * the same place: what this instance runs with is a value that checked out.
247
+ * A config that does not hold leaves the applied one standing and is written
248
+ * to the log, because an instance that was serving a session is not something
249
+ * a typo should take down (§8.3).
250
+ *
251
+ * A config home nothing states settings for runs the built-in ones, which is
252
+ * the unix socket and no mesh: `daemon run` on a directory nobody registered
253
+ * is a thing a person may do. */
254
+ async function configFor(paths: InstancePaths, log: Log, check: boolean): Promise<InstanceConfig> {
255
+ if (!check) {
256
+ // Read, and nothing else: whoever started this instance has already read
257
+ // the files and written down what held.
258
+ const standing = applied(paths.stateRoot);
259
+ return (
260
+ (standing === undefined ? undefined : configOf(standing, paths.configHome)?.config) ??
261
+ DEFAULT_CONFIG
262
+ );
263
+ }
264
+ const settled = await settle(paths.configDir, paths.stateRoot);
265
+ for (const problem of settled.problems) {
266
+ log.write("config refused", { file: problem.file, problem: problem.msg });
267
+ }
268
+ return configOf(settled.satisfied, paths.configHome)?.config ?? DEFAULT_CONFIG;
269
+ }
270
+
226
271
  /** The mesh, on an instance configured for one.
227
272
  *
228
273
  * Two things have to be true: peers to dial, and an address they can dial back.
@@ -234,10 +279,14 @@ function meshFor(
234
279
  log: Log,
235
280
  timing?: MeshTiming,
236
281
  ): Mesh | undefined {
237
- if (config.peers.length === 0 || config.entry === undefined) return undefined;
282
+ const self = config.endpoint;
283
+ if (self === undefined || config.endpoints.length === 0 || config.entry === undefined) {
284
+ return undefined;
285
+ }
238
286
  return new Mesh({
239
287
  id,
240
- peers: config.peers,
288
+ self,
289
+ peers: config.endpoints.map((row) => row.endpoint),
241
290
  conns: new ConnRegistry(),
242
291
  log: (msg, fields) => {
243
292
  log.write(msg, fields);
@@ -259,12 +308,11 @@ export interface MeshWiring {
259
308
  attach(instance: Instance): void;
260
309
  }
261
310
 
262
- /** Bind the WebSocket, settle which endpoint this instance is, and hand both on.
311
+ /** Bind the WebSocket before the instance exists, and hand it on.
263
312
  *
264
- * The listener answers the two pre-authentication routes from the moment it is
265
- * up the probe of self-identification and the key of mesh-peer-auth §6 — and
266
- * refuses everything else until the instance exists, which is a window of one
267
- * round of probes. */
313
+ * A peer may dial the moment the address is up, so the listener answers the one
314
+ * pre-authentication route from that moment the key of mesh-peer-auth §6 —
315
+ * and refuses everything else until there is an instance to answer. */
268
316
  async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiring> {
269
317
  const entry = config.entry as EntryConfig;
270
318
  let instance: Instance | undefined;
@@ -284,16 +332,6 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
284
332
  instance?.accepted(conn, info);
285
333
  },
286
334
  });
287
- try {
288
- await mesh.identify();
289
- } catch (cause) {
290
- // The listener is bound before the endpoint list is checked, so it is this
291
- // function's to release when the check refuses — nothing else holds it yet,
292
- // and a port left bound by a refused start is one the next start cannot
293
- // have.
294
- await ws.close();
295
- throw cause;
296
- }
297
335
  return {
298
336
  conns: mesh.conns,
299
337
  ws,
@@ -675,7 +713,14 @@ export class Instance {
675
713
  // address is what says the caller is local (DR-0001 §2.2).
676
714
  handle: (frame, conn) => {
677
715
  const admin = adminRequestOf(frame);
678
- if (admin !== undefined) return Promise.resolve(handleAdmin(this.#auth, admin));
716
+ if (admin !== undefined) {
717
+ return Promise.resolve(
718
+ handleAdmin(
719
+ { auth: this.#auth, ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }) },
720
+ admin,
721
+ ),
722
+ );
723
+ }
679
724
  return this.handle(frame, conn);
680
725
  },
681
726
  }),
@@ -711,7 +756,7 @@ export class Instance {
711
756
  pid: process.pid,
712
757
  socket: this.paths.socket,
713
758
  http: this.http,
714
- peers: this.config.peers.length,
759
+ peers: this.config.endpoints.length,
715
760
  });
716
761
  }
717
762
 
@@ -2,6 +2,15 @@ import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, isAbsolute, join } from "node:path";
4
4
  import { currentSession, HARNESS } from "../harness/index.ts";
5
+ import {
6
+ CONFIG_FILE,
7
+ ENDPOINTS_FILE,
8
+ INSTANCES_DIR,
9
+ REJECTED_DIR,
10
+ SATISFIED_FILE,
11
+ STATE_CONFIG_DIR,
12
+ SUPERVISOR_FILE,
13
+ } from "./config.ts";
5
14
 
6
15
  /** Every path one instance uses, decided in one place (daemon-v2 §8.1).
7
16
  *
@@ -14,10 +23,23 @@ export interface InstancePaths {
14
23
  readonly configHome: string;
15
24
  /** What distinguishes this instance's files from another instance's. */
16
25
  readonly key: string;
17
- /** The one file a person edits, shared by every instance on this host: it
18
- * carries the defaults and the list of config homes, so it is not derived
19
- * from the config home the way the rest of these are. */
26
+ /** Where a person writes settings, shared by every instance on this host: it
27
+ * holds what every instance starts from and one file per instance, so it is
28
+ * not derived from the config home the way the rest of these are. */
29
+ readonly configDir: string;
30
+ /** The file every instance's settings start from. */
20
31
  readonly configFile: string;
32
+ /** Where the file naming this config home lives, one per instance. */
33
+ readonly instancesDir: string;
34
+ /** The mesh as data, and which of it this host starts. */
35
+ readonly endpointsFile: string;
36
+ readonly supervisorFile: string;
37
+ /** Where what has been read and checked is kept, which is the only thing the
38
+ * supervisor and the instances read (§8.2). */
39
+ readonly stateRoot: string;
40
+ readonly satisfiedFile: string;
41
+ /** Where a file is put before it is overwritten by the checked copy. */
42
+ readonly rejectedDir: string;
21
43
  readonly stateDir: string;
22
44
  /** The address clients connect to. A symlink to whichever `socketReal` is
23
45
  * currently serving, so a client's path outlives the process behind it. */
@@ -111,12 +133,20 @@ export function resolvePaths(env: Env = process.env): InstancePaths {
111
133
  export function resolvePathsFor(configHome: string, env: Env = process.env): InstancePaths {
112
134
  const key = instanceKey(configHome);
113
135
  const configDir = resolveConfigDir(env);
136
+ const stateRoot = resolveStateRoot(env);
114
137
  const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
115
138
  const socketDir = socketDirFor(stateDir, key);
116
139
  return {
117
140
  configHome,
118
141
  key,
119
- configFile: join(configDir, "config.json"),
142
+ configDir,
143
+ configFile: join(configDir, CONFIG_FILE),
144
+ instancesDir: join(configDir, INSTANCES_DIR),
145
+ endpointsFile: join(configDir, ENDPOINTS_FILE),
146
+ supervisorFile: join(configDir, SUPERVISOR_FILE),
147
+ stateRoot,
148
+ satisfiedFile: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
149
+ rejectedDir: join(stateRoot, REJECTED_DIR),
120
150
  stateDir,
121
151
  socketDir,
122
152
  socket: join(socketDir, SOCKET_NAME),
@@ -169,9 +199,10 @@ export function resolveSupervisorSocket(env: Env = process.env): string {
169
199
 
170
200
  export const SUPERVISOR_SOCKET = "supervise.sock";
171
201
 
172
- /** The shared config file, for a caller that has no instance to resolve. */
202
+ /** The file every instance's settings start from, for a caller that has no
203
+ * instance to resolve. */
173
204
  export function resolveConfigFile(env: Env = process.env): string {
174
- return join(resolveConfigDir(env), "config.json");
205
+ return join(resolveConfigDir(env), CONFIG_FILE);
175
206
  }
176
207
 
177
208
  /** A name for one config home that is readable and cannot collide.
package/src/mesh/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export * from "./instances.ts";
2
2
  export * from "./keys.ts";
3
3
  export * from "./mesh.ts";
4
- export * from "./probe.ts";
5
4
  export * from "./relay.ts";
6
5
  export * from "./wire.ts";
package/src/mesh/mesh.ts CHANGED
@@ -30,16 +30,13 @@ import {
30
30
  randomId,
31
31
  verifyProof,
32
32
  } from "./keys.ts";
33
- import { PeerProbe, type PeerReport } from "./probe.ts";
34
33
  import {
35
- isProbePath,
36
34
  jwkEndpoint,
37
35
  type JwkRequest,
38
36
  type JwkResponse,
39
37
  kidOfPath,
40
38
  MESH_PROTOCOL,
41
39
  meshFrameOf,
42
- type ProbeBody,
43
40
  wsEndpoint,
44
41
  } from "./wire.ts";
45
42
 
@@ -135,9 +132,10 @@ export interface MeshHost {
135
132
  export interface MeshDeps {
136
133
  /** This instance's id, which is what it is called on the wire. */
137
134
  readonly id: InstanceId;
138
- /** Every mesh endpoint, this instance's own among them. Which one that is is
139
- * settled by `identify`, not configured (DR-0001 §2.7). */
135
+ /** Every mesh endpoint, this instance's own among them. */
140
136
  readonly peers: readonly Endpoint[];
137
+ /** Which of them is this instance, as the data says (§7.1). */
138
+ readonly self: Endpoint;
141
139
  readonly conns: ConnRegistry;
142
140
  readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
143
141
  /** Something changed about which peers are reachable. */
@@ -259,10 +257,9 @@ export class Mesh {
259
257
  readonly #minted = new Map<string, Minted>();
260
258
  readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
261
259
  readonly #backoff = new Map<Endpoint, number>();
262
- readonly #probe = new PeerProbe();
263
- /** Which of the configured endpoints is this instance, settled by `identify`
260
+ /** Which of the configured endpoints is this instance, as the data said
264
261
  * before anything is dialled and fixed from then on (§5.5). */
265
- #self: Endpoint | undefined;
262
+ readonly #self: Endpoint;
266
263
  /** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
267
264
  * directions: a handshake writes it, `to_instance` reads it to find the link
268
265
  * to dial down, and a disconnection leaves it standing so a peer that is out
@@ -293,6 +290,12 @@ export class Mesh {
293
290
  readonly relay: Relay;
294
291
 
295
292
  constructor(private readonly deps: MeshDeps) {
293
+ this.#self = deps.self;
294
+ // The one binding this instance did not have to learn: its own. That is
295
+ // what makes "an id already answering elsewhere" cover the case of a peer
296
+ // claiming to be us — which is what the instance at a moved instance's old
297
+ // URL looks like from the new one.
298
+ this.#bind(deps.self, deps.id);
296
299
  this.relay = new Relay({
297
300
  publish: (topic, data, instance) => {
298
301
  this.#host?.publish(topic, data, instance);
@@ -334,17 +337,41 @@ export class Mesh {
334
337
  * is what found which entry that is. */
335
338
  get peers(): Endpoint[] {
336
339
  const self = this.self;
337
- return this.deps.peers.filter((peer) => peer !== self);
340
+ return this.deps.peers.filter((peer) => peer !== self && !this.#forgotten.has(peer));
338
341
  }
339
342
 
340
- /** Where peers reach this instance, as the probe settled it (§7.1).
343
+ /** The peers taken off this host's list while this instance was running.
341
344
  *
342
- * Asked only after `identify`: everything that reads it the handshake's
343
- * `aud`, the mesh's own routes, what `hello` reports happens on an instance
344
- * that has already started, and a start where the probe did not settle ends
345
- * instead. */
345
+ * Config is read once (DV-Q8) and this does not change that: what a person
346
+ * writes goes on taking effect at the next start. What this holds is the one
347
+ * edit that cannot wait for one an endpoint this host is no longer to be
348
+ * talking to, which would otherwise stay connected until somebody restarted
349
+ * the instance. */
350
+ readonly #forgotten = new Set<Endpoint>();
351
+
352
+ /** Stop being a peer of this endpoint: drop the link if there is one, stop
353
+ * dialling it, and refuse its greeting if it dials us.
354
+ *
355
+ * Answers whether anything was actually cut, so `ccmsg mesh remove` can say
356
+ * which instances were talking to it rather than that it asked them all. */
357
+ forget(peer: Endpoint): boolean {
358
+ this.#forgotten.add(peer);
359
+ const retry = this.#retries.get(peer);
360
+ if (retry !== undefined) {
361
+ clearTimeout(retry);
362
+ this.#retries.delete(peer);
363
+ }
364
+ const link = this.#links.get(peer);
365
+ if (link === undefined) return false;
366
+ link.conn.close();
367
+ this.#drop(peer, link.conn);
368
+ return true;
369
+ }
370
+
371
+ /** Where peers reach this instance: the row of the mesh carrying its own id
372
+ * (§7.1). Everything that reads it — the handshake's `aud`, the mesh's own
373
+ * routes, what `hello` reports — is the one address the data states. */
346
374
  get self(): Endpoint {
347
- if (this.#self === undefined) throw new Error("this mesh has not identified itself yet");
348
375
  return this.#self;
349
376
  }
350
377
 
@@ -580,24 +607,6 @@ export class Mesh {
580
607
  else conn.send(frame);
581
608
  }
582
609
 
583
- /** Settle which configured endpoint is this instance, before anything is
584
- * dialled (§7.1).
585
- *
586
- * Run once the listener is up, because the probe this instance sends itself
587
- * has to arrive somewhere. A list that reaches this instance no times or
588
- * more than once ends the start; a peer that is merely asleep is recorded
589
- * and dialled later. */
590
- async identify(): Promise<PeerReport> {
591
- const report = await this.#probe.identify(this.deps.peers);
592
- this.#self = report.self;
593
- // The table opens with the one binding this instance did not have to learn:
594
- // its own. That is what makes "an id already answering elsewhere" cover the
595
- // case of a peer claiming to be us — which is what the instance at a moved
596
- // instance's old URL looks like from the new one.
597
- this.#bind(report.self, this.deps.id);
598
- return report;
599
- }
600
-
601
610
  /** Start dialling. Each peer is attempted independently, and a peer that is
602
611
  * not there is retried rather than waited for. */
603
612
  connect(): void {
@@ -620,7 +629,7 @@ export class Mesh {
620
629
  if (claim.ver !== MESH_VER) {
621
630
  throw new OpError("invalid_args", `this instance speaks mesh handshake ${MESH_VER}`);
622
631
  }
623
- if (!this.deps.peers.includes(claim.iss)) {
632
+ if (!this.deps.peers.includes(claim.iss) || this.#forgotten.has(claim.iss)) {
624
633
  throw new OpError("forbidden", `${claim.iss} is not a peer of this instance`);
625
634
  }
626
635
  if (claim.aud !== self) {
@@ -773,45 +782,21 @@ export class Mesh {
773
782
  return this.#pending.has(conn);
774
783
  }
775
784
 
776
- // --- the HTTP surface: the key of §6 and the probe of self-identification ---
785
+ // --- the HTTP surface: the key of §6 ---
777
786
 
778
- /** Answer the two requests that are served before anything is proven, or
779
- * nothing when the request is not one of them. */
787
+ /** Answer the one request that is served before anything is proven, or
788
+ * nothing when the request is not it. */
780
789
  async route(request: Request): Promise<Response | undefined> {
781
790
  const pathname = new URL(request.url).pathname;
782
- // The probe is matched by the end of the path, because it is what settles
783
- // which endpoint this instance is: while one is arriving there is no
784
- // endpoint to hang it under.
785
- if (isProbePath(pathname)) return await this.#answerProbe(request);
786
791
  // The key is below this instance's own endpoint and nowhere else, which is
787
792
  // what keeps two instances on one origin from answering for each other's
788
793
  // keys (mesh-peer-auth §6.3); the person's entry is matched by the end of
789
- // the path instead (DR-0001 §2.7). The probe has settled that endpoint by
790
- // the time any key is asked for: a request arriving before then belongs to
791
- // no handshake, since nothing has been dialled yet.
792
- if (this.#self === undefined) return undefined;
794
+ // the path instead (DR-0001 §2.7).
793
795
  const kid = kidOfPath(pathname, this.#self);
794
796
  if (kid !== undefined) return await this.#serveKey(kid, request);
795
797
  return undefined;
796
798
  }
797
799
 
798
- async #answerProbe(request: Request): Promise<Response> {
799
- let body: unknown;
800
- try {
801
- body = await request.json();
802
- } catch {
803
- return new Response("a probe is a JSON object", { status: 400 });
804
- }
805
- const probe = body as Partial<ProbeBody>;
806
- // An unknown generation is ignored rather than refused: the comparison is
807
- // the sender's, so a receiver that cannot read the probe costs the sender
808
- // nothing it could not already have (§5.1).
809
- if (probe.ver === MESH_VER && typeof probe.token === "string") {
810
- this.#probe.accept(probe.token);
811
- }
812
- return Response.json({});
813
- }
814
-
815
800
  async #serveKey(kid: string, request: Request): Promise<Response> {
816
801
  if (!this.#allowKeyRequest()) {
817
802
  return new Response("too many key requests", { status: 429 });
package/src/mesh/wire.ts CHANGED
@@ -9,15 +9,9 @@ import type { MeshJwk } from "./keys.ts";
9
9
  * sharing one origin apart: the key of `https://h/a/` is only ever fetched from
10
10
  * below `/a/`, so `https://h/b/` cannot answer for it and a proof made with b's
11
11
  * key cannot pass as a's (mesh-peer-auth §6.3). The separation is the shape of
12
- * the URLs rather than a rule written somewhere.
13
- *
14
- * The probe is the exception, and has to be: it is what tells an instance which
15
- * endpoint it is, so while one is arriving there is nothing yet to hang it
16
- * under. */
12
+ * the URLs rather than a rule written somewhere. */
17
13
  const WS_ROUTE = "ws";
18
14
  const JWK_ROUTE = "mesh/jwk/";
19
- const PROBE_ROUTE = "mesh/probe";
20
- const PROBE_PATH = `/${PROBE_ROUTE}`;
21
15
 
22
16
  /** Where a peer's mesh link is dialled.
23
17
  *
@@ -36,10 +30,6 @@ export function jwkEndpoint(endpoint: Endpoint, kid: string): string {
36
30
  return `${endpoint}${JWK_ROUTE}${encodeURIComponent(kid)}`;
37
31
  }
38
32
 
39
- export function probeEndpoint(endpoint: Endpoint): string {
40
- return `${endpoint}${PROBE_ROUTE}`;
41
- }
42
-
43
33
  /** The `kid` a request names, or nothing when the path is not a key request. */
44
34
  export function kidOfPath(pathname: string, self: Endpoint): string | undefined {
45
35
  const prefix = `${new URL(self).pathname}${JWK_ROUTE}`;
@@ -48,18 +38,6 @@ export function kidOfPath(pathname: string, self: Endpoint): string | undefined
48
38
  return kid === "" ? undefined : kid;
49
39
  }
50
40
 
51
- /** Whether this request is a probe.
52
- *
53
- * Matched by the end of the path and not below an endpoint, because a probe is
54
- * what settles which endpoint this instance is: at the moment one arrives there
55
- * is no endpoint to hang it under, and the prefix it came in on is whatever the
56
- * sender's list or a proxy in front of it says. Nothing is decided here anyway
57
- * — the receiver only echoes acceptance, and the comparison belongs to whoever
58
- * minted the token (§5.1). */
59
- export function isProbePath(pathname: string): boolean {
60
- return pathname.endsWith(PROBE_PATH);
61
- }
62
-
63
41
  /** The subprotocol a dialling instance offers.
64
42
  *
65
43
  * A peer is let through the handshake on this marker alone and proves who it is
@@ -97,9 +75,3 @@ export interface JwkRequest {
97
75
  export interface JwkResponse {
98
76
  readonly jwk: MeshJwk;
99
77
  }
100
-
101
- /** What a self-identification probe carries (mesh-self-identification §5.1). */
102
- export interface ProbeBody {
103
- readonly ver: number;
104
- readonly token: string;
105
- }
package/src/mesh/probe.ts DELETED
@@ -1,105 +0,0 @@
1
- import type { Endpoint } from "@ccmsg/protocol";
2
- import { MESH_VER, randomId } from "./keys.ts";
3
- import { probeEndpoint, type ProbeBody } from "./wire.ts";
4
-
5
- /** How long a probe may take to come back.
6
- *
7
- * An endpoint that does not answer is either asleep or misconfigured, and
8
- * waiting longer tells the two apart no better. It bounds startup rather than
9
- * deciding correctness: only the probe that lands back here decides anything. */
10
- export const PROBE_TIMEOUT_MS = 3_000;
11
-
12
- /** The configured endpoints do not say which instance this is.
13
- *
14
- * Its own class so startup can refuse the same way a broken config does (§8.3,
15
- * DV-Q9): a list that names this instance zero times, or twice, is a list that
16
- * cannot be acted on. */
17
- export class SelfEndpointError extends Error {
18
- constructor(msg: string) {
19
- super(msg);
20
- this.name = "SelfEndpointError";
21
- }
22
- }
23
-
24
- /** What one round of probes settled. */
25
- export interface PeerReport {
26
- /** The one configured endpoint that turned out to be this instance. */
27
- readonly self: Endpoint;
28
- /** The endpoints that did not answer. Recorded and not refused: a peer that
29
- * is asleep is the normal state of this mesh (§7.1, DV-Q11). */
30
- readonly unreachable: readonly Endpoint[];
31
- }
32
-
33
- /** The probes in flight, and the endpoint each was sent to.
34
- *
35
- * This is where an instance learns which of the configured endpoints it is
36
- * (mesh-self-identification §5). A `token` is minted per endpoint and sent
37
- * there; the one that arrives back at this process was sent to this process,
38
- * and the endpoint it was addressed to is therefore this instance's own.
39
- *
40
- * Every endpoint is probed, this instance's own included: the probe to
41
- * ourselves is the one that always lands, which is what makes a peer echoing
42
- * a stolen token show up as two matches rather than as a wrong answer (§4.2).
43
- *
44
- * The table is destroyed when the run finishes: what the exercise leaves behind
45
- * is the settled endpoint and nothing else (§7.3). */
46
- export class PeerProbe {
47
- #sent = new Map<string, Endpoint>();
48
- readonly #matched = new Set<Endpoint>();
49
-
50
- /** A probe arrived here. Answering is unconditional and holds no state: the
51
- * comparison is the sender's, and this instance is the sender for exactly one
52
- * of the probes it is currently answering (§5.1). */
53
- accept(token: string): void {
54
- const sentTo = this.#sent.get(token);
55
- if (sentTo !== undefined) this.#matched.add(sentTo);
56
- }
57
-
58
- /** Probe every configured endpoint and settle which one is this instance.
59
- *
60
- * An endpoint that did not answer is left out of the count rather than
61
- * refused, so a mesh whose other host is asleep still starts (DV-Q11): the
62
- * count only ever decides on endpoints that answered, and the probe to
63
- * ourselves always does. */
64
- async identify(peers: readonly Endpoint[]): Promise<PeerReport> {
65
- const targets = [...new Set(peers)];
66
- this.#sent = new Map(targets.map((target) => [randomId(), target]));
67
- const unreachable: Endpoint[] = [];
68
- await Promise.all(
69
- [...this.#sent].map(async ([token, target]) => {
70
- if (!(await this.#probe(target, token))) unreachable.push(target);
71
- }),
72
- );
73
- const matched = [...this.#matched];
74
- this.#sent = new Map();
75
- this.#matched.clear();
76
- if (matched.length !== 1) {
77
- throw new SelfEndpointError(
78
- matched.length === 0
79
- ? `none of the configured endpoints reached this instance: ${targets.join(", ")}`
80
- : `several configured endpoints reach this instance: ${matched.join(", ")}`,
81
- );
82
- }
83
- return { self: matched[0] as Endpoint, unreachable };
84
- }
85
-
86
- /** Whether the endpoint answered. What it answered does not matter: the
87
- * comparison happens where the probe lands, not in its reply. */
88
- async #probe(target: Endpoint, token: string): Promise<boolean> {
89
- const body: ProbeBody = { ver: MESH_VER, token };
90
- try {
91
- const response = await fetch(probeEndpoint(target), {
92
- method: "POST",
93
- // Closed after the one round trip it is: a probe is sent once at
94
- // startup, and a pooled connection kept open for it would outlive the
95
- // exercise and hold the listener at the far end.
96
- headers: { "content-type": "application/json", connection: "close" },
97
- body: JSON.stringify(body),
98
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
99
- });
100
- return response.ok;
101
- } catch {
102
- return false;
103
- }
104
- }
105
- }