@ccmsg/cli 0.2.10 → 0.2.12
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 +2 -2
- package/src/auth/auth.ts +20 -6
- package/src/instance/client.ts +183 -0
- package/src/instance/config.ts +22 -0
- package/src/instance/instance.ts +35 -14
- package/src/sessions/registry.ts +48 -15
- package/src/transcript/files.ts +16 -3
- package/src/transcript/transcripts.ts +4 -2
- package/src/transport/ws.ts +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.8.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/auth.ts
CHANGED
|
@@ -684,12 +684,17 @@ export class Auth {
|
|
|
684
684
|
throw new OpError("auth_invalid", "この refresh token は使えません");
|
|
685
685
|
}
|
|
686
686
|
if (held.body.iss !== this.deps.self) {
|
|
687
|
-
// What the
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
687
|
+
// What the carrier observed goes with the value: the person is at the
|
|
688
|
+
// other end of this instance's connection and not the issuer's, so these
|
|
689
|
+
// are only knowable here, and a rotation forwarded without them would be
|
|
690
|
+
// remembered as a time and nothing else. The issuer writes them
|
|
691
|
+
// unchecked, as it does the ones it observes itself (contract,
|
|
692
|
+
// `AuthRotateArgs`).
|
|
691
693
|
const answer = (await this.#atIssuer(held.body.iss, "auth_rotate", {
|
|
692
694
|
refresh_token: value,
|
|
695
|
+
...(from.reason === undefined ? {} : { reason: from.reason }),
|
|
696
|
+
...(from.ip === undefined ? {} : { ip: from.ip }),
|
|
697
|
+
...(from.userAgent === undefined ? {} : { user_agent: from.userAgent }),
|
|
693
698
|
} satisfies AuthRotateArgs)) as AuthRotateResult;
|
|
694
699
|
return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
|
|
695
700
|
}
|
|
@@ -927,8 +932,17 @@ export function authHandlers(auth: Auth) {
|
|
|
927
932
|
// the URL and the count of tries against it (§2.2).
|
|
928
933
|
return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
|
|
929
934
|
},
|
|
930
|
-
auth_rotate: (input: HandlerInput): AuthRotateResult =>
|
|
931
|
-
|
|
935
|
+
auth_rotate: (input: HandlerInput): AuthRotateResult => {
|
|
936
|
+
const args = input.args as unknown as AuthRotateArgs;
|
|
937
|
+
// The receiving instance's account of the person, taken as stated: it is
|
|
938
|
+
// the only one that saw them, and `last_refresh` is a hint nothing is
|
|
939
|
+
// decided by (contract, `AuthRotateArgs`).
|
|
940
|
+
return auth.rotate(args.refresh_token, {
|
|
941
|
+
...(args.reason === undefined ? {} : { reason: args.reason }),
|
|
942
|
+
...(args.ip === undefined ? {} : { ip: args.ip }),
|
|
943
|
+
...(args.user_agent === undefined ? {} : { userAgent: args.user_agent }),
|
|
944
|
+
});
|
|
945
|
+
},
|
|
932
946
|
};
|
|
933
947
|
}
|
|
934
948
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** Where a request came from, when something in front of us is forwarding it
|
|
2
|
+
* (§3.1).
|
|
3
|
+
*
|
|
4
|
+
* The address the listener observed is the one thing here that cannot be
|
|
5
|
+
* claimed, and behind a reverse proxy it is always the proxy's. `X-Forwarded-*`
|
|
6
|
+
* carries what the proxy saw, but the header is a header: anyone who can reach
|
|
7
|
+
* the port can write one. The operator names the proxies as CIDR blocks, which
|
|
8
|
+
* is the only way this instance can tell a forwarding it asked for from a
|
|
9
|
+
* forwarding a caller invented.
|
|
10
|
+
*
|
|
11
|
+
* What is recovered is a hint and not a credential — an address is what a
|
|
12
|
+
* person recognises their own session by (DR-0001 §2.2), and nothing is
|
|
13
|
+
* admitted or refused by it. That is also why a wrong answer here is worse than
|
|
14
|
+
* no answer: a forged address kept on a record is a hint pointing away from
|
|
15
|
+
* whoever reads it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One address block, as the operator wrote it. Held as the address's bytes and
|
|
19
|
+
* how many of its leading bits the block fixes. */
|
|
20
|
+
interface Cidr {
|
|
21
|
+
readonly bytes: Uint8Array;
|
|
22
|
+
readonly bits: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse an address, or answer nothing for a text that is not one.
|
|
26
|
+
*
|
|
27
|
+
* IPv4 and IPv6 are both read to bytes here rather than compared as text: a
|
|
28
|
+
* block is a run of bits, and two spellings of one address (`::ffff:127.0.0.1`
|
|
29
|
+
* and `127.0.0.1`, `::1` and `0:0:0:0:0:0:0:1`) are the same address. An
|
|
30
|
+
* IPv4-mapped IPv6 address is answered as its four IPv4 bytes, so an operator
|
|
31
|
+
* who wrote `127.0.0.0/8` is not asked to also write the mapped spelling of it.
|
|
32
|
+
*/
|
|
33
|
+
export function addressBytes(text: string): Uint8Array | undefined {
|
|
34
|
+
// A zone id names an interface on the host that holds the address, and says
|
|
35
|
+
// nothing about which address it is.
|
|
36
|
+
const bare = text.includes("%") ? text.slice(0, text.indexOf("%")) : text;
|
|
37
|
+
if (bare === "") return undefined;
|
|
38
|
+
return bare.includes(":") ? ipv6Bytes(bare) : ipv4Bytes(bare);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ipv4Bytes(text: string): Uint8Array | undefined {
|
|
42
|
+
const parts = text.split(".");
|
|
43
|
+
if (parts.length !== 4) return undefined;
|
|
44
|
+
const bytes = new Uint8Array(4);
|
|
45
|
+
for (const [index, part] of parts.entries()) {
|
|
46
|
+
// Leading zeros are refused rather than read: `0177.0.0.1` is one address
|
|
47
|
+
// to a library that reads it as octal and another to one that does not, and
|
|
48
|
+
// an address this instance is unsure of is not one to trust a header by.
|
|
49
|
+
if (!/^(?:0|[1-9][0-9]{0,2})$/.test(part)) return undefined;
|
|
50
|
+
const value = Number(part);
|
|
51
|
+
if (value > 255) return undefined;
|
|
52
|
+
bytes[index] = value;
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ipv6Bytes(text: string): Uint8Array | undefined {
|
|
58
|
+
const halves = text.split("::");
|
|
59
|
+
if (halves.length > 2) return undefined;
|
|
60
|
+
const head = groupsOf(halves[0] ?? "");
|
|
61
|
+
const tail = halves.length === 2 ? groupsOf(halves[1] ?? "") : [];
|
|
62
|
+
if (head === undefined || tail === undefined) return undefined;
|
|
63
|
+
// The address's last group may be written as a dotted IPv4, which is how a
|
|
64
|
+
// mapped address is spelled; it stands for the two groups it fills.
|
|
65
|
+
const last = tail.length > 0 ? tail : head;
|
|
66
|
+
const trailing = last[last.length - 1];
|
|
67
|
+
let embedded: Uint8Array | undefined;
|
|
68
|
+
if (trailing !== undefined && trailing.includes(".")) {
|
|
69
|
+
embedded = ipv4Bytes(trailing);
|
|
70
|
+
if (embedded === undefined) return undefined;
|
|
71
|
+
last.pop();
|
|
72
|
+
}
|
|
73
|
+
const front = bytesOfGroups(head);
|
|
74
|
+
const back = bytesOfGroups(tail);
|
|
75
|
+
if (front === undefined || back === undefined) return undefined;
|
|
76
|
+
const stated = front.length + back.length + (embedded === undefined ? 0 : 4);
|
|
77
|
+
// Without `::` the groups are the whole address; with it they are less than
|
|
78
|
+
// the whole, since it has to stand for at least one group of zeros.
|
|
79
|
+
if (halves.length === 1 ? stated !== 16 : stated > 14) return undefined;
|
|
80
|
+
const bytes = new Uint8Array(16);
|
|
81
|
+
bytes.set(front, 0);
|
|
82
|
+
const rest = new Uint8Array([...back, ...(embedded ?? [])]);
|
|
83
|
+
bytes.set(rest, 16 - rest.length);
|
|
84
|
+
return mappedV4(bytes) ?? bytes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bytesOfGroups(groups: readonly string[]): Uint8Array | undefined {
|
|
88
|
+
const bytes = new Uint8Array(groups.length * 2);
|
|
89
|
+
for (const [index, group] of groups.entries()) {
|
|
90
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return undefined;
|
|
91
|
+
const value = Number.parseInt(group, 16);
|
|
92
|
+
bytes[index * 2] = value >> 8;
|
|
93
|
+
bytes[index * 2 + 1] = value & 0xff;
|
|
94
|
+
}
|
|
95
|
+
return bytes;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The four IPv4 bytes of an IPv4-mapped address (`::ffff:0:0/96`), if it is
|
|
99
|
+
* one. Held as IPv4 so that one written block covers both spellings. */
|
|
100
|
+
function mappedV4(bytes: Uint8Array): Uint8Array | undefined {
|
|
101
|
+
for (let index = 0; index < 10; index += 1) if (bytes[index] !== 0) return undefined;
|
|
102
|
+
if (bytes[10] !== 0xff || bytes[11] !== 0xff) return undefined;
|
|
103
|
+
return bytes.slice(12);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function groupsOf(half: string): string[] | undefined {
|
|
107
|
+
if (half === "") return [];
|
|
108
|
+
const groups = half.split(":");
|
|
109
|
+
return groups.some((group) => group === "") ? undefined : groups;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse `<address>/<bits>`, or a bare address as the block holding it alone.
|
|
113
|
+
* Answers nothing for a text that is not a block, which is what lets config
|
|
114
|
+
* refuse it at load rather than silently trusting nobody. */
|
|
115
|
+
export function parseCidr(text: string): Cidr | undefined {
|
|
116
|
+
const slash = text.lastIndexOf("/");
|
|
117
|
+
const address = slash === -1 ? text : text.slice(0, slash);
|
|
118
|
+
const bytes = addressBytes(address);
|
|
119
|
+
if (bytes === undefined) return undefined;
|
|
120
|
+
const width = bytes.length * 8;
|
|
121
|
+
if (slash === -1) return { bytes, bits: width };
|
|
122
|
+
const suffix = text.slice(slash + 1);
|
|
123
|
+
if (!/^(?:0|[1-9][0-9]?[0-9]?)$/.test(suffix)) return undefined;
|
|
124
|
+
const bits = Number(suffix);
|
|
125
|
+
if (bits > width) return undefined;
|
|
126
|
+
return { bytes, bits };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function within(address: Uint8Array, block: Cidr): boolean {
|
|
130
|
+
// A block fixes bits of one family, so an address of the other is outside it.
|
|
131
|
+
if (address.length !== block.bytes.length) return false;
|
|
132
|
+
const whole = block.bits >> 3;
|
|
133
|
+
for (let index = 0; index < whole; index += 1) {
|
|
134
|
+
if (address[index] !== block.bytes[index]) return false;
|
|
135
|
+
}
|
|
136
|
+
const rest = block.bits & 7;
|
|
137
|
+
if (rest === 0) return true;
|
|
138
|
+
const mask = 0xff << (8 - rest);
|
|
139
|
+
return ((address[whole] ?? 0) & mask) === ((block.bytes[whole] ?? 0) & mask);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether an address is one of the named blocks. */
|
|
143
|
+
export function trusted(address: string | undefined, blocks: readonly Cidr[]): boolean {
|
|
144
|
+
if (address === undefined || blocks.length === 0) return false;
|
|
145
|
+
const bytes = addressBytes(address);
|
|
146
|
+
if (bytes === undefined) return false;
|
|
147
|
+
return blocks.some((block) => within(bytes, block));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The address the person is at, as far as this instance can tell.
|
|
151
|
+
*
|
|
152
|
+
* The observed address when it is not a proxy the operator named — a header
|
|
153
|
+
* from anyone else is a claim about somebody else and is dropped. When it is
|
|
154
|
+
* one, `X-Forwarded-For` is read from the right and the first address that is
|
|
155
|
+
* not itself a trusted proxy is answered: the entries to the right were written
|
|
156
|
+
* by the proxies in the chain, and the first one outside that chain is the last
|
|
157
|
+
* value this instance has any reason to believe. Everything further left was
|
|
158
|
+
* written by whoever was talking to the outermost proxy and could say anything.
|
|
159
|
+
*
|
|
160
|
+
* A chain of nothing but trusted proxies leaves the observed address, which is
|
|
161
|
+
* the honest answer when every value in the header is one of our own hops.
|
|
162
|
+
*/
|
|
163
|
+
export function clientAddress(
|
|
164
|
+
request: Request,
|
|
165
|
+
observed: string | undefined,
|
|
166
|
+
proxies: readonly Cidr[],
|
|
167
|
+
): string | undefined {
|
|
168
|
+
if (!trusted(observed, proxies)) return observed;
|
|
169
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
170
|
+
if (forwarded === null) return observed;
|
|
171
|
+
const hops = forwarded
|
|
172
|
+
.split(",")
|
|
173
|
+
.map((hop) => hop.trim())
|
|
174
|
+
.filter((hop) => hop !== "");
|
|
175
|
+
for (let index = hops.length - 1; index >= 0; index -= 1) {
|
|
176
|
+
const hop = hops[index] ?? "";
|
|
177
|
+
if (addressBytes(hop) === undefined) return observed;
|
|
178
|
+
if (!trusted(hop, proxies)) return hop;
|
|
179
|
+
}
|
|
180
|
+
return observed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type { Cidr };
|
package/src/instance/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute } from "node:path";
|
|
3
3
|
import type { Endpoint } from "@ccmsg/protocol";
|
|
4
|
+
import { parseCidr } from "./client.ts";
|
|
4
5
|
|
|
5
6
|
/** Where the instance accepts WebSocket connections, and from whom.
|
|
6
7
|
*
|
|
@@ -14,6 +15,15 @@ export interface EntryConfig {
|
|
|
14
15
|
/** Source addresses allowed to connect. Empty means every address the bind
|
|
15
16
|
* itself already permits, which for the default loopback bind is this host. */
|
|
16
17
|
readonly source_ips: readonly string[];
|
|
18
|
+
/** Address blocks, in CIDR notation, whose `X-Forwarded-For` this instance
|
|
19
|
+
* believes.
|
|
20
|
+
*
|
|
21
|
+
* Separate from `source_ips` because the two answer different questions: that
|
|
22
|
+
* one is who may connect at all, this one is whose account of somebody else
|
|
23
|
+
* to take. A reverse proxy is commonly allowed in without being the only
|
|
24
|
+
* thing allowed in, and an operator with no proxy leaves this empty and has
|
|
25
|
+
* every forwarding header ignored. */
|
|
26
|
+
readonly trusted_proxies: readonly string[];
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
/** One value a launch recipe's command reads, as the operator declares it. */
|
|
@@ -274,10 +284,22 @@ function entryOf(file: string, raw: unknown): EntryConfig {
|
|
|
274
284
|
if (typeof host !== "string" || host === "") {
|
|
275
285
|
throw new ConfigError(file, "entry.host must be an address to bind");
|
|
276
286
|
}
|
|
287
|
+
const proxies = stringsOf(file, "entry.trusted_proxies", fields["trusted_proxies"]);
|
|
288
|
+
// Read here rather than where a request is: a block that parses to nothing
|
|
289
|
+
// would silently trust nobody, and an operator who wrote one meant to trust
|
|
290
|
+
// somebody.
|
|
291
|
+
const unreadable = proxies.filter((block) => parseCidr(block) === undefined);
|
|
292
|
+
if (unreadable.length > 0) {
|
|
293
|
+
throw new ConfigError(
|
|
294
|
+
file,
|
|
295
|
+
`entry.trusted_proxies must be CIDR blocks, got ${unreadable.join(", ")}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
277
298
|
return {
|
|
278
299
|
host,
|
|
279
300
|
port,
|
|
280
301
|
source_ips: stringsOf(file, "entry.source_ips", fields["source_ips"]),
|
|
302
|
+
trusted_proxies: proxies,
|
|
281
303
|
};
|
|
282
304
|
}
|
|
283
305
|
|
package/src/instance/instance.ts
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
handleAuth,
|
|
88
88
|
recordsDir,
|
|
89
89
|
} from "../auth/index.ts";
|
|
90
|
+
import { type Cidr, clientAddress, parseCidr } from "./client.ts";
|
|
90
91
|
import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
|
|
91
92
|
import { completeHandlers } from "./handlers.ts";
|
|
92
93
|
import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
|
|
@@ -264,7 +265,8 @@ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiri
|
|
|
264
265
|
? Promise.resolve(failure(undefined, "internal_error", "this instance is still starting"))
|
|
265
266
|
: instance.handle(frame, conn),
|
|
266
267
|
entry: entryPolicy(config, true, () => instance?.auth),
|
|
267
|
-
route: async (request) =>
|
|
268
|
+
route: async (request, source) =>
|
|
269
|
+
(await mesh.route(request)) ?? (await instance?.route(request, source)),
|
|
268
270
|
onConn: (conn, info) => {
|
|
269
271
|
mesh.accept(conn, info);
|
|
270
272
|
instance?.accepted(conn, info);
|
|
@@ -313,6 +315,10 @@ export class Instance {
|
|
|
313
315
|
/** The person's authentication: who may open a connection, and the records
|
|
314
316
|
* that say so (DR-0001). */
|
|
315
317
|
readonly #auth: Auth;
|
|
318
|
+
/** The forwarding proxies the operator named, read once: a block is config,
|
|
319
|
+
* and parsing one per request would be work done for every caller to answer
|
|
320
|
+
* a question the config already settled. */
|
|
321
|
+
readonly #proxies: readonly Cidr[];
|
|
316
322
|
readonly #handlers: Handlers;
|
|
317
323
|
readonly #capabilities: ReadonlySet<Capability>;
|
|
318
324
|
/** Set the moment shutdown starts, which is the re-entry guard of §8.5 step
|
|
@@ -343,6 +349,12 @@ export class Instance {
|
|
|
343
349
|
now?: () => Timestamp,
|
|
344
350
|
) {
|
|
345
351
|
this.#conns = wiring?.conns ?? new ConnRegistry();
|
|
352
|
+
// Config refused anything that does not parse, so what is dropped here is
|
|
353
|
+
// nothing an operator wrote.
|
|
354
|
+
this.#proxies = (config.entry?.trusted_proxies ?? []).flatMap((block) => {
|
|
355
|
+
const parsed = parseCidr(block);
|
|
356
|
+
return parsed === undefined ? [] : [parsed];
|
|
357
|
+
});
|
|
346
358
|
this.#mesh = wiring?.mesh;
|
|
347
359
|
this.#boundWs = wiring?.ws;
|
|
348
360
|
// Every capability rests on an upstream, so what is configured is what
|
|
@@ -401,13 +413,23 @@ export class Instance {
|
|
|
401
413
|
},
|
|
402
414
|
});
|
|
403
415
|
|
|
416
|
+
// Which transcript a sid means. Only this instance's config home is ever
|
|
417
|
+
// looked in (M6): a session that greeted said where its transcript is, and
|
|
418
|
+
// one that never greeted is looked for under that home and nowhere else.
|
|
419
|
+
// Both the ops that read a transcript and the tails that follow one ask
|
|
420
|
+
// here, so a sid resolves to the same file whichever way it is reached.
|
|
421
|
+
const transcriptFiles = new TranscriptFiles({
|
|
422
|
+
configHome: paths.configHome,
|
|
423
|
+
announced: (sid) => this.#sessions.transcriptPath(sid),
|
|
424
|
+
});
|
|
425
|
+
|
|
404
426
|
// The transcript tails and their folds. Built before the sessions domain
|
|
405
427
|
// and reading from it lazily: the fold is one of the sessions domain's
|
|
406
428
|
// inputs (§5.1) while the path to follow is one of its outputs, and the
|
|
407
429
|
// two meet at the moment a tail starts rather than at construction.
|
|
408
430
|
this.#transcripts = new Transcripts({
|
|
409
431
|
self: this.self,
|
|
410
|
-
pathOf: (sid) =>
|
|
432
|
+
pathOf: (sid) => transcriptFiles.path(sid),
|
|
411
433
|
publish: (topic, data) => {
|
|
412
434
|
this.#topics.publish(topic, data);
|
|
413
435
|
},
|
|
@@ -433,6 +455,9 @@ export class Instance {
|
|
|
433
455
|
transcript: this.#transcripts,
|
|
434
456
|
gateway: this.#gateway,
|
|
435
457
|
terminals: hostTerminalReader(),
|
|
458
|
+
log: (msg, fields) => {
|
|
459
|
+
this.log.write(msg, fields);
|
|
460
|
+
},
|
|
436
461
|
...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
|
|
437
462
|
onChanged: () => {
|
|
438
463
|
this.#status.refresh();
|
|
@@ -557,15 +582,6 @@ export class Instance {
|
|
|
557
582
|
});
|
|
558
583
|
const origin = config.upstream.sandbox_origin;
|
|
559
584
|
|
|
560
|
-
// Which transcript an op means, for the ops that read one rather than
|
|
561
|
-
// follow one. Only this instance's config home is ever looked in (M6): a
|
|
562
|
-
// session that greeted said where its transcript is, and one that never
|
|
563
|
-
// greeted is looked for under that home and nowhere else.
|
|
564
|
-
const transcriptFiles = new TranscriptFiles({
|
|
565
|
-
configHome: paths.configHome,
|
|
566
|
-
announced: (sid) => this.#sessions.transcriptPath(sid),
|
|
567
|
-
});
|
|
568
|
-
|
|
569
585
|
this.#handlers = completeHandlers({
|
|
570
586
|
hello: this.#sessions.hello,
|
|
571
587
|
session_stopping: this.#sessions.stopping,
|
|
@@ -644,7 +660,7 @@ export class Instance {
|
|
|
644
660
|
},
|
|
645
661
|
// The gateway posts to the address this instance already serves,
|
|
646
662
|
// behind the same entry check (§3.1).
|
|
647
|
-
route: (request) => this.route(request),
|
|
663
|
+
route: (request, source) => this.route(request, source),
|
|
648
664
|
}),
|
|
649
665
|
);
|
|
650
666
|
}
|
|
@@ -665,11 +681,16 @@ export class Instance {
|
|
|
665
681
|
* gateway's webhook is the one such route this instance answers itself; the
|
|
666
682
|
* mesh's two are answered before this is asked, because they are served
|
|
667
683
|
* before anything is proven and this instance's own routes are not. */
|
|
668
|
-
async route(request: Request): Promise<Response | undefined> {
|
|
684
|
+
async route(request: Request, source?: string): Promise<Response | undefined> {
|
|
669
685
|
// The person's authentication comes first: it is the one route reached
|
|
670
686
|
// before anything is proven, and the gateway's webhook carries its own
|
|
671
687
|
// secret and cannot be confused with it (DR-0001 §2.7).
|
|
672
|
-
const
|
|
688
|
+
const ip = clientAddress(request, source, this.#proxies);
|
|
689
|
+
const authorized = await handleAuth(
|
|
690
|
+
request,
|
|
691
|
+
{ auth: this.#auth, self: this.self },
|
|
692
|
+
ip === undefined ? {} : { ip },
|
|
693
|
+
);
|
|
673
694
|
if (authorized !== undefined) return authorized;
|
|
674
695
|
return await this.#gateway.route(request);
|
|
675
696
|
}
|
package/src/sessions/registry.ts
CHANGED
|
@@ -67,6 +67,13 @@ export interface SessionsDeps {
|
|
|
67
67
|
readonly onChanged?: () => void;
|
|
68
68
|
/** How often the confirmation poll runs, for a test that cannot wait. */
|
|
69
69
|
readonly pollMs?: number;
|
|
70
|
+
/** Where this domain says what it declined to act on. A greeting whose
|
|
71
|
+
* `transcript_path` this instance will not read is answered `ok` all the
|
|
72
|
+
* same — the field is simply absent from what `peers` says of the session —
|
|
73
|
+
* and the reason it is absent is operational rather than contractual, so it
|
|
74
|
+
* is written here for `ccmsg daemon log` to answer with. Absent where
|
|
75
|
+
* nothing collects it. */
|
|
76
|
+
readonly log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
70
77
|
/** How the terminal a session runs in is read from its process. Absent on a
|
|
71
78
|
* host where no process's environment can be read, where every row's
|
|
72
79
|
* terminal stays unknown — which is a state the classification has. */
|
|
@@ -245,7 +252,7 @@ export class Sessions implements UpstreamResource {
|
|
|
245
252
|
const expiresAt = this.deps.authExpiresAt?.(input.conn);
|
|
246
253
|
const sid = requiredSid(args);
|
|
247
254
|
if (sid !== undefined) {
|
|
248
|
-
this.register(sid, args
|
|
255
|
+
this.register(sid, args);
|
|
249
256
|
input.conn.onClose(() => this.release(sid));
|
|
250
257
|
}
|
|
251
258
|
return {
|
|
@@ -503,10 +510,15 @@ export class Sessions implements UpstreamResource {
|
|
|
503
510
|
* silence for a retraction would let each of them erase what the last one
|
|
504
511
|
* knew, and the session would be described by whichever process spoke most
|
|
505
512
|
* recently rather than by everything it has said. */
|
|
506
|
-
private register(sid: Sid, args: HelloArgs
|
|
513
|
+
private register(sid: Sid, args: HelloArgs): void {
|
|
507
514
|
const now = Date.now();
|
|
508
515
|
const held = this.#connected.get(sid);
|
|
509
|
-
const meta = {
|
|
516
|
+
const meta = {
|
|
517
|
+
...this.#stated.get(sid),
|
|
518
|
+
...metaOf(args, this.deps.configHome, (refused) => {
|
|
519
|
+
this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
510
522
|
this.#connected.set(sid, {
|
|
511
523
|
sid,
|
|
512
524
|
connected_at: held?.connected_at ?? now,
|
|
@@ -689,15 +701,22 @@ export class Sessions implements UpstreamResource {
|
|
|
689
701
|
* else — a session naming a file elsewhere is a session that named nothing,
|
|
690
702
|
* which is what a session that stayed silent already is (M6). It is not an
|
|
691
703
|
* error: how a session describes itself is its own business, and the instance
|
|
692
|
-
* simply does not act on a description it cannot stand behind.
|
|
693
|
-
|
|
704
|
+
* simply does not act on a description it cannot stand behind. Why a path was
|
|
705
|
+
* not taken is told to `refused`, which is the operator's answer to a field
|
|
706
|
+
* that is simply absent from what `peers` says. */
|
|
707
|
+
function metaOf(
|
|
708
|
+
args: HelloArgs,
|
|
709
|
+
configHome: string,
|
|
710
|
+
refused: (reason: string) => void,
|
|
711
|
+
): SessionMeta {
|
|
694
712
|
const meta: Record<string, string> = {};
|
|
695
713
|
for (const field of META_FIELDS) {
|
|
696
714
|
const value = args[field];
|
|
697
715
|
if (value === undefined) continue;
|
|
698
716
|
if (field === "transcript_path") {
|
|
699
|
-
const
|
|
700
|
-
if (
|
|
717
|
+
const taken = ownTranscript(value, configHome);
|
|
718
|
+
if (typeof taken === "string") meta[field] = taken;
|
|
719
|
+
else refused(taken.refused);
|
|
701
720
|
continue;
|
|
702
721
|
}
|
|
703
722
|
meta[field] = value;
|
|
@@ -721,19 +740,33 @@ function metaOf(args: HelloArgs, configHome: string): SessionMeta {
|
|
|
721
740
|
* symlink and one spelled directly are the same path, and a link anywhere
|
|
722
741
|
* along it that leads out of the tree lands outside and is refused. What is
|
|
723
742
|
* already there must be a file: a directory by that name is not a transcript.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
743
|
+
*
|
|
744
|
+
* `projects/` itself is settled the same way, so a config home whose first
|
|
745
|
+
* session has yet to write anything is a boundary all the same: the directory
|
|
746
|
+
* that is there is followed, the part that is not is taken as spelled, and the
|
|
747
|
+
* comparison is between two paths resolved by one rule. The config home is not
|
|
748
|
+
* treated that way — an instance answers for a home it is running out of, and
|
|
749
|
+
* one that is not there names no tree to be inside of. */
|
|
750
|
+
function ownTranscript(named: string, configHome: string): string | Refused {
|
|
751
|
+
if (!isAbsolute(named)) return { refused: "not an absolute path" };
|
|
752
|
+
let projects: string | undefined;
|
|
728
753
|
try {
|
|
729
|
-
projects =
|
|
754
|
+
projects = resolveAsFarAsItGoes(join(realpathSync(configHome), "projects"));
|
|
730
755
|
} catch {
|
|
731
|
-
return
|
|
756
|
+
return { refused: "the config home is not there" };
|
|
732
757
|
}
|
|
733
758
|
const settled = resolveAsFarAsItGoes(named);
|
|
734
|
-
if (settled === undefined || !within(settled, projects))
|
|
759
|
+
if (projects === undefined || settled === undefined || !within(settled, projects)) {
|
|
760
|
+
return { refused: "outside this config home's projects tree" };
|
|
761
|
+
}
|
|
735
762
|
const stat = statSync(settled, { throwIfNoEntry: false });
|
|
736
|
-
|
|
763
|
+
if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
|
|
764
|
+
return settled;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Why a stated path was not taken, in the words the log states it in. */
|
|
768
|
+
interface Refused {
|
|
769
|
+
readonly refused: string;
|
|
737
770
|
}
|
|
738
771
|
|
|
739
772
|
/** The path with every segment of it that exists resolved.
|
package/src/transcript/files.ts
CHANGED
|
@@ -38,11 +38,24 @@ export interface TranscriptFilesDeps {
|
|
|
38
38
|
export class TranscriptFiles {
|
|
39
39
|
constructor(private readonly deps: TranscriptFilesDeps) {}
|
|
40
40
|
|
|
41
|
-
/** The session's own transcript.
|
|
42
|
-
|
|
41
|
+
/** The session's own transcript, or nothing where this instance holds none.
|
|
42
|
+
*
|
|
43
|
+
* Two ways to the one file, in the order of what each is good for: what the
|
|
44
|
+
* session announced is exact and costs no search, and the walk finds the
|
|
45
|
+
* file by the identity it carries in its name (`<sid>.jsonl`) for a session
|
|
46
|
+
* that never greeted or is no longer running. Both stay inside this
|
|
47
|
+
* instance's `projects/` — the announced path because it was taken only if
|
|
48
|
+
* it was inside it, the walk because that tree is what it walks (M6). */
|
|
49
|
+
path(sid: Sid): string | undefined {
|
|
43
50
|
const announced = this.deps.announced(sid);
|
|
44
51
|
if (announced !== undefined && isFile(announced)) return announced;
|
|
45
|
-
|
|
52
|
+
return this.find(sid);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The session's own transcript, for an op that has nothing to answer
|
|
56
|
+
* without one. */
|
|
57
|
+
session(sid: Sid): string {
|
|
58
|
+
const found = this.path(sid);
|
|
46
59
|
if (found === undefined) throw new OpError("not_found", `no transcript is held for ${sid}`);
|
|
47
60
|
return found;
|
|
48
61
|
}
|
|
@@ -5,8 +5,10 @@ import { type Appended, TranscriptTail } from "./tail.ts";
|
|
|
5
5
|
|
|
6
6
|
export interface TranscriptsDeps {
|
|
7
7
|
readonly self: InstanceId;
|
|
8
|
-
/** Where a session's transcript is
|
|
9
|
-
*
|
|
8
|
+
/** Where a session's transcript is: what it announced when it greeted
|
|
9
|
+
* (§5.1), or the `<sid>.jsonl` under this instance's `projects/` that
|
|
10
|
+
* carries its name. A sid neither names nor is named by a file there has
|
|
11
|
+
* none, and nothing is guessed for it. */
|
|
10
12
|
readonly pathOf: (sid: Sid) => string | undefined;
|
|
11
13
|
/** The one way a value reaches subscribers (§6.1). */
|
|
12
14
|
readonly publish: (topic: string, data: unknown) => void;
|
package/src/transport/ws.ts
CHANGED
|
@@ -39,8 +39,13 @@ export interface WsOptions {
|
|
|
39
39
|
* posts to this instance reaches it at the address it already has, and the
|
|
40
40
|
* entry check of §3.1 runs before this is asked, so a route cannot be
|
|
41
41
|
* reached by anyone the WebSocket could not be. Answering `undefined` leaves
|
|
42
|
-
* the request to the upgrade, which refuses it.
|
|
43
|
-
|
|
42
|
+
* the request to the upgrade, which refuses it.
|
|
43
|
+
*
|
|
44
|
+
* `source` is the peer address the server observed, passed for the reason
|
|
45
|
+
* `allowRequest` is given it: what a route can be told about where a request
|
|
46
|
+
* came from is written by whoever is in front of us, and only the listener
|
|
47
|
+
* knows who that actually was. */
|
|
48
|
+
readonly route?: (request: Request, source: string | undefined) => Promise<Response | undefined>;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/** Accept the webui, and later mesh peers, over WebSocket.
|
|
@@ -61,10 +66,11 @@ export function serveWs(options: WsOptions): Listener {
|
|
|
61
66
|
hostname: options.hostname ?? "127.0.0.1",
|
|
62
67
|
port: options.port,
|
|
63
68
|
async fetch(request, srv) {
|
|
64
|
-
|
|
69
|
+
const source = srv.requestIP(request)?.address;
|
|
70
|
+
if (entry.allowRequest?.(request, source) === false) {
|
|
65
71
|
return new Response("Forbidden", { status: 403 });
|
|
66
72
|
}
|
|
67
|
-
const routed = await options.route?.(request);
|
|
73
|
+
const routed = await options.route?.(request, source);
|
|
68
74
|
if (routed !== undefined) return routed;
|
|
69
75
|
if (!entryPath(new URL(request.url).pathname, path)) {
|
|
70
76
|
return new Response("Not Found", { status: 404 });
|