@ccmsg/cli 0.2.10 → 0.2.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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.7.0"
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 client said about this refresh stays here: `auth_rotate`
688
- // carries the value and nothing else, and the address the issuer would
689
- // see is this instance's rather than the person's. The issuer records
690
- // that the family rotated, which is the part it can vouch for.
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
- auth.rotate((input.args as unknown as AuthRotateArgs).refresh_token),
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 };
@@ -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
 
@@ -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) => (await mesh.route(request)) ?? (await instance?.route(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
@@ -644,7 +656,7 @@ export class Instance {
644
656
  },
645
657
  // The gateway posts to the address this instance already serves,
646
658
  // behind the same entry check (§3.1).
647
- route: (request) => this.route(request),
659
+ route: (request, source) => this.route(request, source),
648
660
  }),
649
661
  );
650
662
  }
@@ -665,11 +677,16 @@ export class Instance {
665
677
  * gateway's webhook is the one such route this instance answers itself; the
666
678
  * mesh's two are answered before this is asked, because they are served
667
679
  * before anything is proven and this instance's own routes are not. */
668
- async route(request: Request): Promise<Response | undefined> {
680
+ async route(request: Request, source?: string): Promise<Response | undefined> {
669
681
  // The person's authentication comes first: it is the one route reached
670
682
  // before anything is proven, and the gateway's webhook carries its own
671
683
  // secret and cannot be confused with it (DR-0001 §2.7).
672
- const authorized = await handleAuth(request, { auth: this.#auth, self: this.self }, {});
684
+ const ip = clientAddress(request, source, this.#proxies);
685
+ const authorized = await handleAuth(
686
+ request,
687
+ { auth: this.#auth, self: this.self },
688
+ ip === undefined ? {} : { ip },
689
+ );
673
690
  if (authorized !== undefined) return authorized;
674
691
  return await this.#gateway.route(request);
675
692
  }
@@ -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
- readonly route?: (request: Request) => Promise<Response | undefined>;
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
- if (entry.allowRequest?.(request, srv.requestIP(request)?.address) === false) {
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 });