@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.
- package/package.json +1 -1
- package/src/auth/admin.ts +32 -6
- package/src/cli.ts +377 -57
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +398 -70
- package/src/daemon/supervise.ts +27 -3
- package/src/instance/ccmsg-config.d.ts +112 -0
- package/src/instance/config.ts +513 -131
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +71 -26
- package/src/instance/paths.ts +37 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +47 -62
- package/src/mesh/wire.ts +1 -29
- package/src/mesh/probe.ts +0 -105
package/src/instance/identity.ts
CHANGED
|
@@ -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 =
|
|
38
|
+
const made = newId();
|
|
30
39
|
mkdirSync(dirname(file), { recursive: true });
|
|
31
40
|
writeFileSync(file, `${made}\n`);
|
|
32
41
|
return made;
|
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 =
|
|
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,
|
|
@@ -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)
|
|
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.
|
|
759
|
+
peers: this.config.endpoints.length,
|
|
715
760
|
});
|
|
716
761
|
}
|
|
717
762
|
|
package/src/instance/paths.ts
CHANGED
|
@@ -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
|
-
/**
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
|
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),
|
|
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
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);
|
|
@@ -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
|
-
/**
|
|
343
|
+
/** The peers taken off this host's list while this instance was running.
|
|
341
344
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* that
|
|
345
|
-
*
|
|
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
|
|
785
|
+
// --- the HTTP surface: the key of §6 ---
|
|
777
786
|
|
|
778
|
-
/** Answer the
|
|
779
|
-
* 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. */
|
|
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).
|
|
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
|
-
}
|