@ccmsg/cli 0.2.8 → 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 +2 -2
- package/src/auth/auth.ts +51 -5
- package/src/auth/http.ts +7 -1
- package/src/auth/webauthn.ts +15 -3
- package/src/instance/client.ts +183 -0
- package/src/instance/config.ts +22 -0
- package/src/instance/instance.ts +21 -4
- package/src/service/service.ts +74 -14
- 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.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.
|
|
23
|
+
"@ccmsg/protocol": "1.8.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/auth/auth.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AuthChallengeResult,
|
|
6
6
|
AuthRecord,
|
|
7
7
|
AuthRefreshArgs,
|
|
8
|
+
AuthRefreshReason,
|
|
8
9
|
AuthRefreshResult,
|
|
9
10
|
AuthRegisterArgs,
|
|
10
11
|
AuthResolveArgs,
|
|
@@ -145,6 +146,18 @@ export interface MintedSession {
|
|
|
145
146
|
readonly refresh: { readonly value: Base64Url; readonly expires_at: Timestamp };
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/** What is known about the client that asked for a refresh: its own word for
|
|
150
|
+
* why, and what the carrier observed of it.
|
|
151
|
+
*
|
|
152
|
+
* Kept on the family as `last_refresh` and read by nobody but the person whose
|
|
153
|
+
* sessions they are — a run of `reconnect` at an hour they were asleep is
|
|
154
|
+
* something to recognise. Nothing here is checked, so nothing may turn on it. */
|
|
155
|
+
export interface RefreshFrom {
|
|
156
|
+
readonly reason?: AuthRefreshReason;
|
|
157
|
+
readonly ip?: string;
|
|
158
|
+
readonly userAgent?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
/** What a registration URL is, as the command that made it prints it. */
|
|
149
162
|
export interface IssuedRegistration {
|
|
150
163
|
readonly sub: Subject;
|
|
@@ -455,6 +468,11 @@ export class Auth {
|
|
|
455
468
|
endpoint: claims.endpoint,
|
|
456
469
|
rp_id: claims.rp_id,
|
|
457
470
|
sign_count: verified.signCount,
|
|
471
|
+
// What the authenticator said about backing this credential up, kept
|
|
472
|
+
// because it decides what removing the line costs the person and nothing
|
|
473
|
+
// else: neither flag is ever read to admit or refuse an exchange.
|
|
474
|
+
backup_eligible: verified.backupEligible,
|
|
475
|
+
backup_state: verified.backupState,
|
|
458
476
|
...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
|
|
459
477
|
...(args.device_label === undefined ? {} : { device_label: args.device_label }),
|
|
460
478
|
registered_at: at,
|
|
@@ -656,7 +674,7 @@ export class Auth {
|
|
|
656
674
|
* a family minted elsewhere is carried there rather than done here — two
|
|
657
675
|
* instances rotating one family in parallel would merge by last write and
|
|
658
676
|
* read exactly like a stolen token being replayed (§2.4). */
|
|
659
|
-
async refreshToken(value: Base64Url): Promise<MintedSession> {
|
|
677
|
+
async refreshToken(value: Base64Url, from: RefreshFrom = {}): Promise<MintedSession> {
|
|
660
678
|
const held = this.deps.records.byRefresh(value);
|
|
661
679
|
if (held === undefined) {
|
|
662
680
|
// Not the standing generation, nor the one before it. Either it never was
|
|
@@ -666,12 +684,21 @@ export class Auth {
|
|
|
666
684
|
throw new OpError("auth_invalid", "この refresh token は使えません");
|
|
667
685
|
}
|
|
668
686
|
if (held.body.iss !== this.deps.self) {
|
|
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`).
|
|
669
693
|
const answer = (await this.#atIssuer(held.body.iss, "auth_rotate", {
|
|
670
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 }),
|
|
671
698
|
} satisfies AuthRotateArgs)) as AuthRotateResult;
|
|
672
699
|
return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
|
|
673
700
|
}
|
|
674
|
-
const rotated = this.rotate(value);
|
|
701
|
+
const rotated = this.rotate(value, from);
|
|
675
702
|
return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
|
|
676
703
|
}
|
|
677
704
|
|
|
@@ -703,7 +730,7 @@ export class Auth {
|
|
|
703
730
|
|
|
704
731
|
/** Rotate a family this instance minted. The one writer's own operation, and
|
|
705
732
|
* what `auth_rotate` runs on its behalf. */
|
|
706
|
-
rotate(value: Base64Url): AuthRotateResult {
|
|
733
|
+
rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
|
|
707
734
|
const held = this.deps.records.byRefresh(value);
|
|
708
735
|
if (held === undefined) {
|
|
709
736
|
this.#failReused(value);
|
|
@@ -732,6 +759,16 @@ export class Auth {
|
|
|
732
759
|
iss: this.deps.self,
|
|
733
760
|
access,
|
|
734
761
|
refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
|
|
762
|
+
// Written by this instance because it is the family's `iss`, and only for
|
|
763
|
+
// the rotation that just happened — the caller's word about why, and
|
|
764
|
+
// where it was asked from, are a hint for the person reading their own
|
|
765
|
+
// sessions back and are never checked (contract, `TokenFamily`).
|
|
766
|
+
last_refresh: {
|
|
767
|
+
at,
|
|
768
|
+
...(from.reason === undefined ? {} : { reason: from.reason }),
|
|
769
|
+
...(from.ip === undefined ? {} : { ip: from.ip }),
|
|
770
|
+
...(from.userAgent === undefined ? {} : { user_agent: from.userAgent }),
|
|
771
|
+
},
|
|
735
772
|
previous_refresh: { value: held.body.refresh.value, expires_at: at + PREVIOUS_GRACE_MS },
|
|
736
773
|
// The value going out of service is remembered as a digest for as long as
|
|
737
774
|
// it would have been accepted, so that presenting it later is recognised
|
|
@@ -895,8 +932,17 @@ export function authHandlers(auth: Auth) {
|
|
|
895
932
|
// the URL and the count of tries against it (§2.2).
|
|
896
933
|
return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
|
|
897
934
|
},
|
|
898
|
-
auth_rotate: (input: HandlerInput): AuthRotateResult =>
|
|
899
|
-
|
|
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
|
+
},
|
|
900
946
|
};
|
|
901
947
|
}
|
|
902
948
|
|
package/src/auth/http.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
type AuthAssertArgs,
|
|
4
|
+
type AuthRefreshTokenArgs,
|
|
4
5
|
type AuthRegisterArgs,
|
|
5
6
|
type ErrorCode,
|
|
6
7
|
type InstanceId,
|
|
@@ -196,7 +197,12 @@ export async function handleAuth(
|
|
|
196
197
|
if (held === undefined) {
|
|
197
198
|
return refusal("auth_invalid", "この要求には refresh token がありません", cors);
|
|
198
199
|
}
|
|
199
|
-
const
|
|
200
|
+
const { reason } = args as unknown as AuthRefreshTokenArgs;
|
|
201
|
+
const minted = await deps.auth.refreshToken(held, {
|
|
202
|
+
...(reason === undefined ? {} : { reason }),
|
|
203
|
+
...(seen.ip === undefined ? {} : { ip: seen.ip }),
|
|
204
|
+
...(seen.userAgent === undefined ? {} : { userAgent: seen.userAgent }),
|
|
205
|
+
});
|
|
200
206
|
return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
|
|
201
207
|
}
|
|
202
208
|
}
|
package/src/auth/webauthn.ts
CHANGED
|
@@ -9,11 +9,15 @@ import { type CborValue, decodeCbor, decodeCborWhole, mapEntry } from "./cbor.ts
|
|
|
9
9
|
* log; nothing branches on it. */
|
|
10
10
|
export class WebAuthnError extends Error {}
|
|
11
11
|
|
|
12
|
-
/** The flags of the authenticator data (L2 §6.1).
|
|
13
|
-
* person was present, and that they were verified
|
|
14
|
-
* `userVerification: "required"`, so both have to
|
|
12
|
+
/** The flags of the authenticator data (L2 §6.1). Two of them decide whether an
|
|
13
|
+
* exchange is admitted: that a person was present, and that they were verified
|
|
14
|
+
* — the registration asks for `userVerification: "required"`, so both have to
|
|
15
|
+
* hold on every exchange. The two backup flags decide nothing; they are read at
|
|
16
|
+
* registration and kept as a hint for the person reading their own list. */
|
|
15
17
|
const FLAG_USER_PRESENT = 0x01;
|
|
16
18
|
const FLAG_USER_VERIFIED = 0x04;
|
|
19
|
+
const FLAG_BACKUP_ELIGIBLE = 0x08;
|
|
20
|
+
const FLAG_BACKUP_STATE = 0x10;
|
|
17
21
|
const FLAG_ATTESTED_CREDENTIAL = 0x40;
|
|
18
22
|
|
|
19
23
|
export function base64UrlDecode(value: string): Uint8Array {
|
|
@@ -131,6 +135,12 @@ export interface VerifiedRegistration {
|
|
|
131
135
|
readonly credentialId: Base64Url;
|
|
132
136
|
readonly publicKey: Base64Url;
|
|
133
137
|
readonly signCount: number;
|
|
138
|
+
/** The BE flag: whether the authenticator may back this credential up, which
|
|
139
|
+
* is what separates a synced passkey from one that lives on a single device. */
|
|
140
|
+
readonly backupEligible: boolean;
|
|
141
|
+
/** The BS flag: whether it was backed up at this moment. Eligible and not yet
|
|
142
|
+
* backed up is an ordinary state on a device that has just made the key. */
|
|
143
|
+
readonly backupState: boolean;
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
/** Check a registration (L2 §7.1) and answer what is worth keeping.
|
|
@@ -180,6 +190,8 @@ export function verifyRegistration(
|
|
|
180
190
|
credentialId: base64UrlEncode(data.credentialId),
|
|
181
191
|
publicKey: base64UrlEncode(data.publicKey),
|
|
182
192
|
signCount: data.signCount,
|
|
193
|
+
backupEligible: (data.flags & FLAG_BACKUP_ELIGIBLE) !== 0,
|
|
194
|
+
backupState: (data.flags & FLAG_BACKUP_STATE) !== 0,
|
|
183
195
|
};
|
|
184
196
|
}
|
|
185
197
|
|
|
@@ -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
|
|
@@ -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
|
|
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
|
}
|
package/src/service/service.ts
CHANGED
|
@@ -150,16 +150,43 @@ export function serviceFor(env: Env = process.env, platform = process.platform):
|
|
|
150
150
|
throw new CommandError("capability_unavailable", `${platform} には登録先がありません`);
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
/** Whether launchd's refusal to bootstrap is it saying the unit is already
|
|
154
|
+
* there. Errno 37 is `EBUSY`, which is what a bootstrap racing the teardown of
|
|
155
|
+
* the same label gets. */
|
|
156
|
+
function alreadyLoaded(answer: RunResult): boolean {
|
|
157
|
+
const said = `${answer.stdout} ${answer.stderr}`;
|
|
158
|
+
return /already (loaded|bootstrapped)|Operation already in progress|: 37:/.test(said);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What the init system said when it refused.
|
|
162
|
+
*
|
|
163
|
+
* The command and its stderr, unabridged: launchd's refusals are numbered
|
|
164
|
+
* rather than worded, and a `Bootstrap failed: 5: Input/output error` handed
|
|
165
|
+
* straight to the person is worth more than anything this could say instead. */
|
|
166
|
+
function refuse(command: readonly string[], answer: RunResult): never {
|
|
167
|
+
const said = answer.stderr.trim() || answer.stdout.trim();
|
|
168
|
+
throw new CommandError(
|
|
169
|
+
"internal_error",
|
|
170
|
+
`${command.join(" ")} が失敗しました (exit ${String(answer.code)})${said === "" ? "" : `: ${said}`}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class LaunchdService implements Service {
|
|
154
175
|
readonly kind = "launchd" as const;
|
|
155
176
|
readonly unitFile: string;
|
|
177
|
+
readonly #label: string;
|
|
156
178
|
readonly #env: Env;
|
|
157
179
|
readonly #domain: string;
|
|
158
180
|
|
|
159
|
-
|
|
181
|
+
/** The label is a parameter for `Run`'s reason: a test that drives this
|
|
182
|
+
* machine's real launchd has to do it under a name that is not the one the
|
|
183
|
+
* machine's own supervisor is registered under. Nothing in ccmsg passes it —
|
|
184
|
+
* `serviceFor` is where the name is settled. */
|
|
185
|
+
constructor(env: Env, label: string = LAUNCHD_LABEL) {
|
|
160
186
|
this.#env = env;
|
|
187
|
+
this.#label = label;
|
|
161
188
|
const home = env["HOME"] ?? homedir();
|
|
162
|
-
this.unitFile = join(home, "Library", "LaunchAgents", `${
|
|
189
|
+
this.unitFile = join(home, "Library", "LaunchAgents", `${label}.plist`);
|
|
163
190
|
this.#domain = `gui/${String(process.getuid?.() ?? 0)}`;
|
|
164
191
|
}
|
|
165
192
|
|
|
@@ -173,7 +200,7 @@ class LaunchdService implements Service {
|
|
|
173
200
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
174
201
|
'<plist version="1.0">',
|
|
175
202
|
"<dict>",
|
|
176
|
-
` <key>Label</key><string>${
|
|
203
|
+
` <key>Label</key><string>${this.#label}</string>`,
|
|
177
204
|
" <key>ProgramArguments</key>",
|
|
178
205
|
" <array>",
|
|
179
206
|
...supervisorCommand().map((arg) => ` <string>${escapeXml(arg)}</string>`),
|
|
@@ -194,26 +221,48 @@ class LaunchdService implements Service {
|
|
|
194
221
|
].join("\n");
|
|
195
222
|
}
|
|
196
223
|
|
|
224
|
+
/** Registering is writing the unit and starting it: a supervisor nothing is
|
|
225
|
+
* supervising is not what the person asked for, and `RunAtLoad` means that a
|
|
226
|
+
* unit put in front of launchd at all is a unit launchd runs. What `start`
|
|
227
|
+
* adds is the case where the file is already there. */
|
|
197
228
|
async register(run: Run): Promise<ServiceState> {
|
|
198
229
|
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
199
|
-
await
|
|
200
|
-
return await this.state(run);
|
|
230
|
+
return await this.start(run);
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
204
|
-
await run(["launchctl", "bootout", `${this.#domain}/${
|
|
234
|
+
await run(["launchctl", "bootout", `${this.#domain}/${this.#label}`]);
|
|
205
235
|
const existed = existsSync(this.unitFile);
|
|
206
236
|
rmSync(this.unitFile, { force: true });
|
|
207
237
|
return { unregistered: existed };
|
|
208
238
|
}
|
|
209
239
|
|
|
240
|
+
/** Put the unit in front of launchd if it is not already there, then start
|
|
241
|
+
* the program.
|
|
242
|
+
*
|
|
243
|
+
* `kickstart` alone is what a loaded unit needs, and launchd answers a unit
|
|
244
|
+
* it has never heard of with `No such process` — which is exactly the state
|
|
245
|
+
* a login leaves behind, and the state `unregister` leaves behind however
|
|
246
|
+
* quickly a `register` follows it. So what is loaded is read first and the
|
|
247
|
+
* missing half is done here rather than assumed to have been done by
|
|
248
|
+
* whoever wrote the file. */
|
|
210
249
|
async start(run: Run): Promise<ServiceState> {
|
|
211
|
-
|
|
250
|
+
const before = await this.#report(run);
|
|
251
|
+
if (before.service?.loaded !== true) {
|
|
252
|
+
const bootstrap = ["launchctl", "bootstrap", this.#domain, this.unitFile];
|
|
253
|
+
const answer = await run(bootstrap);
|
|
254
|
+
// `Operation already in progress` and `Service is already loaded` are
|
|
255
|
+
// launchd saying the unit is there, which is all this asked for.
|
|
256
|
+
if (answer.code !== 0 && !alreadyLoaded(answer)) refuse(bootstrap, answer);
|
|
257
|
+
}
|
|
258
|
+
const kickstart = ["launchctl", "kickstart", `${this.#domain}/${this.#label}`];
|
|
259
|
+
const answer = await run(kickstart);
|
|
260
|
+
if (answer.code !== 0) refuse(kickstart, answer);
|
|
212
261
|
return await this.state(run);
|
|
213
262
|
}
|
|
214
263
|
|
|
215
264
|
async stop(run: Run): Promise<ServiceState> {
|
|
216
|
-
await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${
|
|
265
|
+
await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${this.#label}`]);
|
|
217
266
|
return await this.state(run);
|
|
218
267
|
}
|
|
219
268
|
|
|
@@ -226,7 +275,7 @@ class LaunchdService implements Service {
|
|
|
226
275
|
|
|
227
276
|
async #report(run: Run): Promise<Omit<ServiceState, "program">> {
|
|
228
277
|
const registered = existsSync(this.unitFile);
|
|
229
|
-
const printed = await run(["launchctl", "print", `${this.#domain}/${
|
|
278
|
+
const printed = await run(["launchctl", "print", `${this.#domain}/${this.#label}`]);
|
|
230
279
|
// A non-zero exit is launchd saying it has no such service, which is not
|
|
231
280
|
// the same as having nothing to say: the report stays `null` only when the
|
|
232
281
|
// question could not be put, and here it was and the answer was "no".
|
|
@@ -294,11 +343,10 @@ class SystemdService implements Service {
|
|
|
294
343
|
return { kind: "command", show: [...unit, "--no-pager"], follow: [...unit, "-f"] };
|
|
295
344
|
}
|
|
296
345
|
|
|
346
|
+
/** `LaunchdService.register`'s reasoning, in systemd's vocabulary. */
|
|
297
347
|
async register(run: Run): Promise<ServiceState> {
|
|
298
348
|
write(this.unitFile, this.unitText(), serviceLogFile(this.#env));
|
|
299
|
-
await run
|
|
300
|
-
await run(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT]);
|
|
301
|
-
return await this.state(run);
|
|
349
|
+
return await this.start(run);
|
|
302
350
|
}
|
|
303
351
|
|
|
304
352
|
async unregister(run: Run): Promise<{ unregistered: boolean }> {
|
|
@@ -309,8 +357,20 @@ class SystemdService implements Service {
|
|
|
309
357
|
return { unregistered: existed };
|
|
310
358
|
}
|
|
311
359
|
|
|
360
|
+
/** `LaunchdService.start`'s reasoning: a unit systemd has not read is a unit
|
|
361
|
+
* `start` answers `not found` for, and a file written since the last reload
|
|
362
|
+
* is exactly that. `enable --now` starts it and puts it in the target, so a
|
|
363
|
+
* supervisor registered today is running after the next login. */
|
|
312
364
|
async start(run: Run): Promise<ServiceState> {
|
|
313
|
-
|
|
365
|
+
const before = await this.#report(run);
|
|
366
|
+
if (before.service?.loaded !== true) {
|
|
367
|
+
const reload = ["systemctl", "--user", "daemon-reload"];
|
|
368
|
+
const reloaded = await run(reload);
|
|
369
|
+
if (reloaded.code !== 0) refuse(reload, reloaded);
|
|
370
|
+
}
|
|
371
|
+
const enable = ["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT];
|
|
372
|
+
const answer = await run(enable);
|
|
373
|
+
if (answer.code !== 0) refuse(enable, answer);
|
|
314
374
|
return await this.state(run);
|
|
315
375
|
}
|
|
316
376
|
|
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 });
|