@ccmsg/cli 0.10.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.
- package/package.json +1 -1
- package/src/cli.ts +301 -198
- package/src/daemon/registry.ts +151 -128
- package/src/daemon/supervise.ts +16 -4
- package/src/instance/ccmsg-config.d.ts +10 -9
- package/src/instance/config.ts +374 -310
- package/src/instance/identity.ts +3 -3
- package/src/instance/instance.ts +63 -25
- package/src/instance/paths.ts +24 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +18 -61
- package/src/mesh/wire.ts +1 -29
- package/src/mesh/probe.ts +0 -105
package/src/instance/identity.ts
CHANGED
|
@@ -13,9 +13,9 @@ export const ID = /^[0-9a-f]{32}$/;
|
|
|
13
13
|
|
|
14
14
|
/** A fresh id of that width, for whatever is being named.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
19
|
export function newId(): string {
|
|
20
20
|
return randomBytes(ID_BYTES).toString("hex");
|
|
21
21
|
}
|
package/src/instance/instance.ts
CHANGED
|
@@ -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 {
|
|
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 = await
|
|
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
|
|
211
|
+
// 5. the mesh, for an instance the data names an address for.
|
|
196
212
|
//
|
|
197
|
-
// Which entry of
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
311
|
+
/** Bind the WebSocket before the instance exists, and hand it on.
|
|
263
312
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* refuses everything else until
|
|
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,
|
|
@@ -718,7 +756,7 @@ export class Instance {
|
|
|
718
756
|
pid: process.pid,
|
|
719
757
|
socket: this.paths.socket,
|
|
720
758
|
http: this.http,
|
|
721
|
-
peers: this.config.
|
|
759
|
+
peers: this.config.endpoints.length,
|
|
722
760
|
});
|
|
723
761
|
}
|
|
724
762
|
|
package/src/instance/paths.ts
CHANGED
|
@@ -2,7 +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 {
|
|
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";
|
|
6
14
|
|
|
7
15
|
/** Every path one instance uses, decided in one place (daemon-v2 §8.1).
|
|
8
16
|
*
|
|
@@ -23,9 +31,15 @@ export interface InstancePaths {
|
|
|
23
31
|
readonly configFile: string;
|
|
24
32
|
/** Where the file naming this config home lives, one per instance. */
|
|
25
33
|
readonly instancesDir: string;
|
|
26
|
-
/**
|
|
27
|
-
readonly
|
|
28
|
-
readonly
|
|
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;
|
|
29
43
|
readonly stateDir: string;
|
|
30
44
|
/** The address clients connect to. A symlink to whichever `socketReal` is
|
|
31
45
|
* currently serving, so a client's path outlives the process behind it. */
|
|
@@ -119,6 +133,7 @@ export function resolvePaths(env: Env = process.env): InstancePaths {
|
|
|
119
133
|
export function resolvePathsFor(configHome: string, env: Env = process.env): InstancePaths {
|
|
120
134
|
const key = instanceKey(configHome);
|
|
121
135
|
const configDir = resolveConfigDir(env);
|
|
136
|
+
const stateRoot = resolveStateRoot(env);
|
|
122
137
|
const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
|
|
123
138
|
const socketDir = socketDirFor(stateDir, key);
|
|
124
139
|
return {
|
|
@@ -127,8 +142,11 @@ export function resolvePathsFor(configHome: string, env: Env = process.env): Ins
|
|
|
127
142
|
configDir,
|
|
128
143
|
configFile: join(configDir, CONFIG_FILE),
|
|
129
144
|
instancesDir: join(configDir, INSTANCES_DIR),
|
|
130
|
-
|
|
131
|
-
|
|
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),
|
|
132
150
|
stateDir,
|
|
133
151
|
socketDir,
|
|
134
152
|
socket: join(socketDir, SOCKET_NAME),
|
package/src/mesh/index.ts
CHANGED
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.
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -365,14 +368,10 @@ export class Mesh {
|
|
|
365
368
|
return true;
|
|
366
369
|
}
|
|
367
370
|
|
|
368
|
-
/** Where peers reach this instance
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
* `aud`, the mesh's own routes, what `hello` reports — happens on an instance
|
|
372
|
-
* that has already started, and a start where the probe did not settle ends
|
|
373
|
-
* instead. */
|
|
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. */
|
|
374
374
|
get self(): Endpoint {
|
|
375
|
-
if (this.#self === undefined) throw new Error("this mesh has not identified itself yet");
|
|
376
375
|
return this.#self;
|
|
377
376
|
}
|
|
378
377
|
|
|
@@ -608,24 +607,6 @@ export class Mesh {
|
|
|
608
607
|
else conn.send(frame);
|
|
609
608
|
}
|
|
610
609
|
|
|
611
|
-
/** Settle which configured endpoint is this instance, before anything is
|
|
612
|
-
* dialled (§7.1).
|
|
613
|
-
*
|
|
614
|
-
* Run once the listener is up, because the probe this instance sends itself
|
|
615
|
-
* has to arrive somewhere. A list that reaches this instance no times or
|
|
616
|
-
* more than once ends the start; a peer that is merely asleep is recorded
|
|
617
|
-
* and dialled later. */
|
|
618
|
-
async identify(): Promise<PeerReport> {
|
|
619
|
-
const report = await this.#probe.identify(this.deps.peers);
|
|
620
|
-
this.#self = report.self;
|
|
621
|
-
// The table opens with the one binding this instance did not have to learn:
|
|
622
|
-
// its own. That is what makes "an id already answering elsewhere" cover the
|
|
623
|
-
// case of a peer claiming to be us — which is what the instance at a moved
|
|
624
|
-
// instance's old URL looks like from the new one.
|
|
625
|
-
this.#bind(report.self, this.deps.id);
|
|
626
|
-
return report;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
610
|
/** Start dialling. Each peer is attempted independently, and a peer that is
|
|
630
611
|
* not there is retried rather than waited for. */
|
|
631
612
|
connect(): void {
|
|
@@ -801,45 +782,21 @@ export class Mesh {
|
|
|
801
782
|
return this.#pending.has(conn);
|
|
802
783
|
}
|
|
803
784
|
|
|
804
|
-
// --- the HTTP surface: the key of §6
|
|
785
|
+
// --- the HTTP surface: the key of §6 ---
|
|
805
786
|
|
|
806
|
-
/** Answer the
|
|
807
|
-
* nothing when the request is not
|
|
787
|
+
/** Answer the one request that is served before anything is proven, or
|
|
788
|
+
* nothing when the request is not it. */
|
|
808
789
|
async route(request: Request): Promise<Response | undefined> {
|
|
809
790
|
const pathname = new URL(request.url).pathname;
|
|
810
|
-
// The probe is matched by the end of the path, because it is what settles
|
|
811
|
-
// which endpoint this instance is: while one is arriving there is no
|
|
812
|
-
// endpoint to hang it under.
|
|
813
|
-
if (isProbePath(pathname)) return await this.#answerProbe(request);
|
|
814
791
|
// The key is below this instance's own endpoint and nowhere else, which is
|
|
815
792
|
// what keeps two instances on one origin from answering for each other's
|
|
816
793
|
// keys (mesh-peer-auth §6.3); the person's entry is matched by the end of
|
|
817
|
-
// the path instead (DR-0001 §2.7).
|
|
818
|
-
// the time any key is asked for: a request arriving before then belongs to
|
|
819
|
-
// no handshake, since nothing has been dialled yet.
|
|
820
|
-
if (this.#self === undefined) return undefined;
|
|
794
|
+
// the path instead (DR-0001 §2.7).
|
|
821
795
|
const kid = kidOfPath(pathname, this.#self);
|
|
822
796
|
if (kid !== undefined) return await this.#serveKey(kid, request);
|
|
823
797
|
return undefined;
|
|
824
798
|
}
|
|
825
799
|
|
|
826
|
-
async #answerProbe(request: Request): Promise<Response> {
|
|
827
|
-
let body: unknown;
|
|
828
|
-
try {
|
|
829
|
-
body = await request.json();
|
|
830
|
-
} catch {
|
|
831
|
-
return new Response("a probe is a JSON object", { status: 400 });
|
|
832
|
-
}
|
|
833
|
-
const probe = body as Partial<ProbeBody>;
|
|
834
|
-
// An unknown generation is ignored rather than refused: the comparison is
|
|
835
|
-
// the sender's, so a receiver that cannot read the probe costs the sender
|
|
836
|
-
// nothing it could not already have (§5.1).
|
|
837
|
-
if (probe.ver === MESH_VER && typeof probe.token === "string") {
|
|
838
|
-
this.#probe.accept(probe.token);
|
|
839
|
-
}
|
|
840
|
-
return Response.json({});
|
|
841
|
-
}
|
|
842
|
-
|
|
843
800
|
async #serveKey(kid: string, request: Request): Promise<Response> {
|
|
844
801
|
if (!this.#allowKeyRequest()) {
|
|
845
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
|
-
}
|