ccdeck 3.29.10 → 3.30.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/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The sockets. Every decision this makes lives in lan-sync.mjs
|
|
2
|
-
//
|
|
3
|
-
// a
|
|
1
|
+
// The sockets. Every decision this makes lives in lan-sync.mjs and
|
|
2
|
+
// lan-wire.mjs, or for an invite in lan-invite.mjs; what is here is the
|
|
3
|
+
// plumbing that decision layer refuses to own — a UDP socket that shouts, a TCP
|
|
4
|
+
// listener that answers, and the deadlines around both. The shouting half is
|
|
5
|
+
// lan-beacon.mjs and the calling half lan-call.mjs, and the line a dialler and
|
|
6
|
+
// this listener speak is lan-lines.mjs's; this file is the answering, and it
|
|
7
|
+
// re-exports the other two, so what imports them from here still does.
|
|
4
8
|
//
|
|
5
9
|
// TWO SOCKETS, AND NEITHER IS THE DECK'S HTTP SERVER. That server binds
|
|
6
10
|
// 127.0.0.1 and stays there. It has a mutation guard that deliberately trusts a
|
|
@@ -8,144 +12,26 @@
|
|
|
8
12
|
// correct on loopback, and total exposure the moment the same server answers
|
|
9
13
|
// the network. So this feature never asks anybody to run `--host`: it opens its
|
|
10
14
|
// own listener, that listener speaks one protocol and nothing else, and it
|
|
11
|
-
//
|
|
12
|
-
//
|
|
15
|
+
// serves no frame to anybody who has not proved they hold the key of a deck
|
|
16
|
+
// somebody here accepted.
|
|
13
17
|
//
|
|
14
18
|
// DISCOVERY IS BROADCAST ON A FIXED PORT; THE SYNC LISTENER IS EPHEMERAL and
|
|
15
19
|
// says its port in the beacon. One fixed port rather than two is one thing to
|
|
16
20
|
// collide with, one firewall dialog, and one number in a support answer.
|
|
17
|
-
//
|
|
18
|
-
// A DECK HEARS ITSELF. Measured, not assumed: a broadcast to 255.255.255.255
|
|
19
|
-
// comes back to every socket on the sending machine bound to that port, from
|
|
20
|
-
// the machine's own LAN address rather than from loopback. That is what makes
|
|
21
|
-
// two decks on one machine find each other — which is how this gets tested at
|
|
22
|
-
// all — and it is why self-recognition is by fingerprint rather than by
|
|
23
|
-
// address.
|
|
24
|
-
import dgram from "node:dgram";
|
|
25
|
-
import { networkInterfaces } from "node:os";
|
|
26
|
-
import { looksLikeTunnel } from "./route-via.mjs";
|
|
27
|
-
|
|
28
|
-
/** An IPv4 dotted quad as four numbers, or null for anything that is not one. */
|
|
29
|
-
/**
|
|
30
|
-
* What each refusal reason means, in a sentence the LAN panel can print.
|
|
31
|
-
*
|
|
32
|
-
* At module scope so it is one object rather than one per frame, and named so
|
|
33
|
-
* the `Object.hasOwn` guard below reads as the rule it is rather than as
|
|
34
|
-
* punctuation. The keys are the protocol's, not a user's.
|
|
35
|
-
*/
|
|
36
|
-
const REFUSALS = Object.freeze({
|
|
37
|
-
pending: "waiting for the other deck to accept this one",
|
|
38
|
-
declined: "that deck said no",
|
|
39
|
-
"invite only": "that deck pairs only by invite",
|
|
40
|
-
// Sent back to a caller that said it was not asking, so the far end's answer
|
|
41
|
-
// is about the caller's own setting — the same words its own round uses.
|
|
42
|
-
"not asking": "this deck pairs only by invite",
|
|
43
|
-
impostor: "that deck has this one pinned under a different key",
|
|
44
|
-
"bad proof": "the other deck refused this one's proof",
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
function quad(text) {
|
|
48
|
-
const parts = String(text ?? "").split(".");
|
|
49
|
-
if (parts.length !== 4) return null;
|
|
50
|
-
const out = parts.map(p => Number(p));
|
|
51
|
-
return out.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? out : null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The address that reaches every host on one interface's own subnet.
|
|
56
|
-
*
|
|
57
|
-
* `address | ~netmask`, which is the definition. Null for a /32, because a
|
|
58
|
-
* point-to-point link — a VPN tunnel, `utun` on macOS — has a directed
|
|
59
|
-
* broadcast equal to its own address, and sending a beacon to ourselves down a
|
|
60
|
-
* tunnel is a packet nobody wanted.
|
|
61
|
-
*/
|
|
62
|
-
export function directedBroadcast(address, netmask) {
|
|
63
|
-
const a = quad(address);
|
|
64
|
-
const m = quad(netmask);
|
|
65
|
-
if (!a || !m) return null;
|
|
66
|
-
if (m.every(o => o === 255)) return null;
|
|
67
|
-
return a.map((o, i) => o | (~m[i] & 255)).join(".");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Where a beacon has to go to be heard on this machine's networks.
|
|
72
|
-
*
|
|
73
|
-
* The limited broadcast first, because it is the one that works where a router
|
|
74
|
-
* or an access point filters the directed form, and because it is what every
|
|
75
|
-
* deck before this version sent — a machine that was fine stays fine. Then one
|
|
76
|
-
* per interface, which is what a multi-homed host actually needs: see announce.
|
|
77
|
-
*
|
|
78
|
-
* Loopback and IPv6 are skipped. A deck on `lo0` can only hear itself, and this
|
|
79
|
-
* protocol is IPv4 broadcast by construction — there is no such thing as an
|
|
80
|
-
* IPv6 broadcast address.
|
|
81
|
-
*/
|
|
82
|
-
export function broadcastTargets(ifaces) {
|
|
83
|
-
return broadcastPlan(ifaces).map(p => p.to);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The same targets, each with the interface whose subnet it is — null for the
|
|
88
|
-
* limited broadcast, which is nobody's. What lets a beacon be held back when
|
|
89
|
-
* the machine would send it out by some OTHER interface: see leavesByTunnel.
|
|
90
|
-
*/
|
|
91
|
-
export function broadcastPlan(ifaces) {
|
|
92
|
-
const out = [{ to: "255.255.255.255", iface: null }];
|
|
93
|
-
for (const [name, list] of Object.entries(ifaces ?? {})) {
|
|
94
|
-
for (const ni of list ?? []) {
|
|
95
|
-
if (!ni || ni.internal) continue;
|
|
96
|
-
// Node 18 reports `family` as the string "IPv4"; older shapes used 4.
|
|
97
|
-
if (ni.family !== "IPv4" && ni.family !== 4) continue;
|
|
98
|
-
const to = directedBroadcast(ni.address, ni.netmask);
|
|
99
|
-
if (to && !out.some(p => p.to === to)) out.push({ to, iface: name });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return out;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Would this broadcast leave the local network?
|
|
107
|
-
*
|
|
108
|
-
* A directed broadcast belongs to the interface whose subnet it is, so the
|
|
109
|
-
* machine sending it out by any other one means something rerouted the local
|
|
110
|
-
* network — a VPN, or a Tailscale exit node without local network access —
|
|
111
|
-
* and the beacon would come out on the far end of the tunnel. The limited
|
|
112
|
-
* broadcast belongs to none, so it is held back only when it would go into
|
|
113
|
-
* something that is plainly a tunnel. Unknown answers nothing: send.
|
|
114
|
-
*/
|
|
115
|
-
export function leavesByTunnel(target, via, isTunnel) {
|
|
116
|
-
if (!via) return false;
|
|
117
|
-
if (target.iface) return via !== target.iface;
|
|
118
|
-
return isTunnel(via);
|
|
119
|
-
}
|
|
120
21
|
import net from "node:net";
|
|
121
|
-
import { randomBytes } from "node:crypto";
|
|
122
22
|
import {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
readEphemeral, readPub, sealsFrames, sessionKey, trustedPeer,
|
|
126
|
-
ANNOUNCE_MS, MAX_BEACON_BYTES, MAX_MANIFEST_BYTES,
|
|
23
|
+
challengeFor, cleanName, ephemeralPair, frameChannel, handshakeTranscript, mixesEphemeral, proof, proofOk,
|
|
24
|
+
readChallenge, readEphemeral, readPub, sealsFrames, sessionKey, trustedPeer,
|
|
127
25
|
} from "./lan-sync.mjs";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
export
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* milliseconds — so five seconds is generous for a slow machine and short
|
|
138
|
-
* enough that holding sockets open costs an attacker something. */
|
|
139
|
-
export const HANDSHAKE_MS = 5_000;
|
|
140
|
-
|
|
141
|
-
/** The most one frame may be, and the most a peer may hold open.
|
|
142
|
-
*
|
|
143
|
-
* Frames are JSON lines. The largest legitimate one is a manifest; the cap is
|
|
144
|
-
* an order of magnitude over the biggest real store, and the buffer is
|
|
145
|
-
* ABANDONED rather than grown past it — a socket that keeps sending without a
|
|
146
|
-
* newline is trying to make this allocate, and the answer is to stop reading
|
|
147
|
-
* rather than to read faster. */
|
|
148
|
-
export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
|
|
26
|
+
import { inviteProof, inviteProofBack } from "./lan-invite.mjs";
|
|
27
|
+
// The line both halves speak — the reader, the writer, the cap on a frame and
|
|
28
|
+
// the deadline on a handshake — is lan-lines.mjs's. Re-exported, so what
|
|
29
|
+
// imports it from here still does.
|
|
30
|
+
import { frameReader, sendFrame, HANDSHAKE_MS } from "./lan-lines.mjs";
|
|
31
|
+
export { frameReader, sendFrame, HANDSHAKE_MS, MAX_FRAME_BYTES } from "./lan-lines.mjs";
|
|
32
|
+
// The calling half — dialling, proving, and reading a refusal — is
|
|
33
|
+
// lan-call.mjs's. Re-exported, so what dials through this file still does.
|
|
34
|
+
export { connectToPeer } from "./lan-call.mjs";
|
|
149
35
|
|
|
150
36
|
/** Concurrent connections from all peers together. Small on purpose: the real
|
|
151
37
|
* number is one per peer per minute, and anything above this is either a bug
|
|
@@ -198,414 +84,6 @@ export const MAX_SOCKETS_PER_HOST = 4;
|
|
|
198
84
|
*/
|
|
199
85
|
export const IDLE_MS = 30_000;
|
|
200
86
|
|
|
201
|
-
/** The shortest gap between two "I am here too" replies to a stranger. Long
|
|
202
|
-
* enough that a burst of decks starting together cannot make a storm, short
|
|
203
|
-
* enough that starting two decks by hand feels instant. */
|
|
204
|
-
const REPLY_COOLDOWN_MS = 2_000;
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The shouting half.
|
|
208
|
-
*
|
|
209
|
-
* `reuseAddr` is not a convenience: without it a second deck on the same
|
|
210
|
-
* machine cannot bind the discovery port at all, and two decks on one machine
|
|
211
|
-
* is both a real setup and the only way this gets tested here.
|
|
212
|
-
*
|
|
213
|
-
* Errors are reported, never thrown. A machine with no route, a firewall that
|
|
214
|
-
* refuses the bind, an interface that comes and goes with a VPN — none of them
|
|
215
|
-
* is a reason for the deck to fall over, and all of them are reasons for the
|
|
216
|
-
* panel to be able to say what happened.
|
|
217
|
-
*/
|
|
218
|
-
export function createBeacon({
|
|
219
|
-
port, name, fp, onPeer, onStranger, onError, onIdClash, now = Date.now,
|
|
220
|
-
/** The decks somebody has accepted, read fresh each packet so an accept takes
|
|
221
|
-
* effect immediately rather than at the next restart. */
|
|
222
|
-
trusted = () => [],
|
|
223
|
-
// Injected so the suite can drive this with a socket it controls. CI runners
|
|
224
|
-
// are not a network: GitHub's have no broadcast domain worth the name, and a
|
|
225
|
-
// test that quietly skipped there would be a test that stopped testing
|
|
226
|
-
// without saying so. The REAL socket is exercised by hand, two decks on one
|
|
227
|
-
// machine, which works because a broadcast comes back to its own host.
|
|
228
|
-
createSocket = opts => dgram.createSocket(opts),
|
|
229
|
-
// Injected for the same reason, and read PER ANNOUNCE rather than once: a
|
|
230
|
-
// laptop that joins a network, or brings a VPN up, grows an interface without
|
|
231
|
-
// restarting the deck, and a list captured at start would announce to the
|
|
232
|
-
// addresses it had at breakfast.
|
|
233
|
-
ifaces = () => networkInterfaces(),
|
|
234
|
-
/**
|
|
235
|
-
* Addresses to send the beacon to one by one, beside the broadcast — the
|
|
236
|
-
* owner's machines on Tailscale, whose tunnel carries no broadcast at all.
|
|
237
|
-
* Read per announce like `ifaces`, because the tailnet list is re-read while
|
|
238
|
-
* the deck runs and a machine that just came online belongs in the next one.
|
|
239
|
-
*/
|
|
240
|
-
unicast = () => [],
|
|
241
|
-
/**
|
|
242
|
-
* Which way a packet from this address came: "lan", "tailscale", or null for
|
|
243
|
-
* one to ignore entirely. Null is what a tailnet packet gets while this
|
|
244
|
-
* deck's Tailscale switch is off — the sockets hear it either way, because
|
|
245
|
-
* they bind every interface, and off has to mean the deck does not act on it.
|
|
246
|
-
*/
|
|
247
|
-
routeFor = () => "lan",
|
|
248
|
-
/** How long to wait before trying the discovery port again while another
|
|
249
|
-
* program holds it, and who to tell when hearing stops or comes back. */
|
|
250
|
-
rebindMs = 30_000,
|
|
251
|
-
onHearing,
|
|
252
|
-
/**
|
|
253
|
-
* Where the machine would send each broadcast — see createRouteCheck in
|
|
254
|
-
* route-via.mjs. Absent, every broadcast goes, as before; the suite's
|
|
255
|
-
* beacons have none.
|
|
256
|
-
*/
|
|
257
|
-
routes = null,
|
|
258
|
-
} = {}) {
|
|
259
|
-
// Randomised per process. Two beacons from one fingerprint with different
|
|
260
|
-
// instance ids mean the deck restarted between them, which is the signal to
|
|
261
|
-
// drop any session held for it rather than resume into a process that is gone.
|
|
262
|
-
const instance = randomBytes(8).toString("hex");
|
|
263
|
-
/** Which computer this is, as opposed to which process or which key. Derived
|
|
264
|
-
* once per beacon rather than per packet: it cannot change while a process
|
|
265
|
-
* is running, and hashing a hostname thirty seconds apart forever is work
|
|
266
|
-
* nobody asked for. See hostId. */
|
|
267
|
-
const host = hostId();
|
|
268
|
-
const peers = new Map();
|
|
269
|
-
let sock = null;
|
|
270
|
-
/**
|
|
271
|
-
* The socket beacons LEAVE by, on a port of its own. See start.
|
|
272
|
-
*
|
|
273
|
-
* Null until it is bound, and whenever it could not be — the listening
|
|
274
|
-
* socket then sends as it always did, because a beacon from the wrong port is
|
|
275
|
-
* still a beacon and none at all is a deck nobody finds.
|
|
276
|
-
*/
|
|
277
|
-
let out = null;
|
|
278
|
-
let timer = null;
|
|
279
|
-
/**
|
|
280
|
-
* Whether this deck can HEAR — whether it holds the discovery port.
|
|
281
|
-
*
|
|
282
|
-
* SEPARATE FROM RUNNING, because a deck that cannot bind 45317 is still most
|
|
283
|
-
* of a deck. It announces from its own socket, so every other deck still
|
|
284
|
-
* finds it, asks it and dials it; what it has lost is hearing them announce.
|
|
285
|
-
* Stopping the whole feature over that — which is what this did — took a
|
|
286
|
-
* working sync away from somebody whose Tailscale exit node happened to be
|
|
287
|
-
* holding the port. It keeps trying for the port instead, and takes it back
|
|
288
|
-
* the moment it is free.
|
|
289
|
-
*/
|
|
290
|
-
let hearing = false;
|
|
291
|
-
let deafError = null;
|
|
292
|
-
/** Whether the last announce held back every broadcast because the local
|
|
293
|
-
* network goes through a tunnel here — see leavesByTunnel. */
|
|
294
|
-
let tunneled = false;
|
|
295
|
-
let told = null;
|
|
296
|
-
let rebind = null;
|
|
297
|
-
let stopped = true;
|
|
298
|
-
// Socket binds finish asynchronously. A stopped start must not attach its
|
|
299
|
-
// late socket to a subsequent start or leave an outbound socket open.
|
|
300
|
-
let startGeneration = 0;
|
|
301
|
-
/** When this deck last answered a deck it had not heard, so answering cannot
|
|
302
|
-
* become a storm, and which decks it has already answered — without the
|
|
303
|
-
* second, a deck that is never accepted is answered again on every packet
|
|
304
|
-
* for as long as both are running. */
|
|
305
|
-
let repliedAt = 0;
|
|
306
|
-
const answered = new Set();
|
|
307
|
-
|
|
308
|
-
const payload = () => Buffer.from(JSON.stringify(beaconPayload({ name, fp, port, instance, host })));
|
|
309
|
-
|
|
310
|
-
const announce = (also = []) => {
|
|
311
|
-
if (!sock && !out) return;
|
|
312
|
-
const announcedIn = startGeneration;
|
|
313
|
-
// EVERY BROADCAST ADDRESS THIS MACHINE HAS, not one.
|
|
314
|
-
//
|
|
315
|
-
// This used to send only to 255.255.255.255, on the argument that the
|
|
316
|
-
// limited broadcast "needs nothing" while a subnet-directed one needs the
|
|
317
|
-
// netmask of whichever interface the packet leaves by. The argument is
|
|
318
|
-
// sound and the machine disagreed with it. Measured on a Mac with Wi-Fi and
|
|
319
|
-
// a VPN tunnel up, from a bare node process with no deck involved:
|
|
320
|
-
//
|
|
321
|
-
// send to 255.255.255.255 -> EHOSTUNREACH
|
|
322
|
-
// send to 192.168.1.255 -> sent ok
|
|
323
|
-
//
|
|
324
|
-
// On a multi-homed host the limited broadcast has no single interface to
|
|
325
|
-
// leave by, and macOS refuses it rather than choosing. The deck went on
|
|
326
|
-
// announcing into nothing for as long as that machine was up: it could still
|
|
327
|
-
// HEAR colleagues, because receiving is per-port and not per-address, so the
|
|
328
|
-
// symptom was one-sided and looked like everybody else's problem.
|
|
329
|
-
//
|
|
330
|
-
// So the limited form stays — it is the one that works where a directed
|
|
331
|
-
// broadcast is filtered, and it is what Syncthing sends — and every
|
|
332
|
-
// interface's own directed broadcast goes out beside it. A duplicate packet
|
|
333
|
-
// costs one datagram; a missing one costs the whole feature.
|
|
334
|
-
const plan = broadcastPlan(ifaces());
|
|
335
|
-
// Asked behind the send, and read from what was last answered: the first
|
|
336
|
-
// beacon after a start goes as it always did, and the ones after it know.
|
|
337
|
-
void routes?.want?.(plan.map(p => p.to));
|
|
338
|
-
const kept = routes ? plan.filter(p => !leavesByTunnel(p, routes.via(p.to), looksLikeTunnel)) : plan;
|
|
339
|
-
tunneled = kept.length === 0;
|
|
340
|
-
const targets = kept.map(p => p.to);
|
|
341
|
-
const from = out ?? sock;
|
|
342
|
-
let left = targets.length;
|
|
343
|
-
const failed = [];
|
|
344
|
-
for (const to of targets) {
|
|
345
|
-
from.send(payload(), DISCOVERY_PORT, to, err => {
|
|
346
|
-
// A send may finish after this socket has closed and a new LAN
|
|
347
|
-
// session has started. Its result says nothing about the new session.
|
|
348
|
-
if (stopped || announcedIn !== startGeneration) return;
|
|
349
|
-
if (err) failed.push(`${to} (${err.code ?? err.message})`);
|
|
350
|
-
// REPORTED ONLY WHEN EVERY ONE FAILED. One address being unreachable is
|
|
351
|
-
// the ordinary state of a machine with a VPN up, and a panel that said
|
|
352
|
-
// so every thirty seconds would be crying wolf about a working deck.
|
|
353
|
-
// No address working at all is a deck nobody can discover, which is
|
|
354
|
-
// exactly what the panel is for.
|
|
355
|
-
if (--left === 0 && failed.length === targets.length) {
|
|
356
|
-
onError?.("announce", new Error(`no broadcast address worked — ${failed.join(", ")}`));
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
// AND ONE PACKET PER TAILNET MACHINE. Not part of the verdict above: a node
|
|
361
|
-
// that has gone to sleep since the list was read is the ordinary state of a
|
|
362
|
-
// laptop, and it says nothing about whether this deck can be discovered.
|
|
363
|
-
let direct = [];
|
|
364
|
-
try { direct = [...(unicast() ?? []), ...also]; } catch { direct = [...also]; }
|
|
365
|
-
for (const to of new Set(direct)) {
|
|
366
|
-
if (typeof to !== "string" || !to || targets.includes(to)) continue;
|
|
367
|
-
from.send(payload(), DISCOVERY_PORT, to, () => {});
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
const onMessage = (msg, rinfo) => {
|
|
372
|
-
// A queued UDP callback can run after close(). It belongs to the stopped
|
|
373
|
-
// discovery session and must not repopulate peers or invite strangers.
|
|
374
|
-
if (stopped) return;
|
|
375
|
-
// Everything about whether to care lives in lan-sync.mjs. This hands it
|
|
376
|
-
// the bytes and the address and does what it is told.
|
|
377
|
-
if (msg.length > MAX_BEACON_BYTES) return;
|
|
378
|
-
let via = "lan";
|
|
379
|
-
try { via = routeFor(rinfo.address); } catch { via = "lan"; }
|
|
380
|
-
if (!via) return;
|
|
381
|
-
const beacon = readBeacon(msg);
|
|
382
|
-
const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
|
|
383
|
-
// ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
|
|
384
|
-
// part is the change. It used to answer only a deck already in the group,
|
|
385
|
-
// which was fine when a group existed. Now the first thing a new deck has
|
|
386
|
-
// to become is a row on somebody's screen, and it cannot become one if
|
|
387
|
-
// this deck never tells it that it exists.
|
|
388
|
-
//
|
|
389
|
-
// Measured on two real decks before any of this: deck 1 saw deck 2 the
|
|
390
|
-
// instant it started and deck 2 saw nobody, because deck 1's own
|
|
391
|
-
// immediate announce went out before deck 2 was listening. Thirty seconds
|
|
392
|
-
// of an empty list is how a working feature reads as broken.
|
|
393
|
-
//
|
|
394
|
-
// At most once every few seconds, because the obvious version is a shout
|
|
395
|
-
// storm: two decks answering each other's answers forever. A new pair
|
|
396
|
-
// converges in two extra packets.
|
|
397
|
-
const newToUs = beacon && verdict !== "self" && verdict !== "id-clash" && verdict !== "unreadable"
|
|
398
|
-
&& !peers.has(beacon.fp) && !answered.has(beacon.fp);
|
|
399
|
-
if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
|
|
400
|
-
repliedAt = now();
|
|
401
|
-
answered.add(beacon.fp);
|
|
402
|
-
// A deck that reached this one over the tailnet is answered there too:
|
|
403
|
-
// a broadcast never gets back down its tunnel.
|
|
404
|
-
announce(via === "tailscale" ? [rinfo.address] : []);
|
|
405
|
-
}
|
|
406
|
-
if (verdict !== "peer") {
|
|
407
|
-
// Another deck is using this one's key — see beaconVerdict. Reported
|
|
408
|
-
// rather than fixed here: this file carries packets, and choosing a new
|
|
409
|
-
// identity for the deck belongs to whoever stores it.
|
|
410
|
-
if (verdict === "id-clash") onIdClash?.();
|
|
411
|
-
// A DECK NOBODY HAS ACCEPTED. It is not refused and not silently
|
|
412
|
-
// dropped: it is a name and an address on the same network, which is a
|
|
413
|
-
// row somebody can accept. Nothing is asked of it and nothing is
|
|
414
|
-
// offered to it until they do.
|
|
415
|
-
if (verdict === "stranger") {
|
|
416
|
-
onStranger?.({
|
|
417
|
-
fp: beacon.fp, name: beacon.name, addr: rinfo.address, port: beacon.port,
|
|
418
|
-
// Carried through so the list can show one row per machine rather
|
|
419
|
-
// than one per key that machine has ever held.
|
|
420
|
-
host: beacon.host, at: now(), via,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
const noted = notePeer(peers, beacon, rinfo.address, now(), via);
|
|
426
|
-
if (noted.changed || noted.restarted) onPeer?.(noted);
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
/** Tell whoever asked, once per change rather than once per try. */
|
|
430
|
-
const tell = now => {
|
|
431
|
-
if (told === now) return;
|
|
432
|
-
told = now;
|
|
433
|
-
onHearing?.(now);
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
/** One try at the discovery port: the listening socket, or the error that
|
|
437
|
-
* kept it. A bind that fails never calls back; it arrives as an error. */
|
|
438
|
-
const listen = (startedIn) => new Promise(resolve => {
|
|
439
|
-
let s;
|
|
440
|
-
try { s = createSocket({ type: "udp4", reuseAddr: true }); } catch (err) { resolve({ err }); return; }
|
|
441
|
-
let bound = false;
|
|
442
|
-
s.on("error", err => {
|
|
443
|
-
if (bound) {
|
|
444
|
-
if (!stopped && startedIn === startGeneration) onError?.("socket", err);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
try { s.close(); } catch { /* never opened */ }
|
|
448
|
-
resolve({ err });
|
|
449
|
-
});
|
|
450
|
-
// close() can leave a message callback queued. After restart, stopped is
|
|
451
|
-
// false again, so the socket's own generation must also be checked.
|
|
452
|
-
s.on("message", (msg, rinfo) => {
|
|
453
|
-
if (startedIn !== startGeneration) return;
|
|
454
|
-
onMessage(msg, rinfo);
|
|
455
|
-
});
|
|
456
|
-
s.bind(DISCOVERY_PORT, "0.0.0.0", () => {
|
|
457
|
-
bound = true;
|
|
458
|
-
try { s.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
459
|
-
resolve({ sock: s });
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
const tryListen = async (startedIn) => {
|
|
464
|
-
if (stopped || startedIn !== startGeneration) return;
|
|
465
|
-
const got = await listen(startedIn);
|
|
466
|
-
if (stopped || startedIn !== startGeneration) { try { got.sock?.close(); } catch { /* gone */ } return; }
|
|
467
|
-
if (got.sock) {
|
|
468
|
-
sock = got.sock;
|
|
469
|
-
hearing = true;
|
|
470
|
-
deafError = null;
|
|
471
|
-
tell(true);
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
hearing = false;
|
|
475
|
-
deafError = got.err;
|
|
476
|
-
tell(false);
|
|
477
|
-
rebind = setTimeout(() => { rebind = null; void tryListen(startedIn); }, rebindMs);
|
|
478
|
-
rebind.unref?.();
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* The socket beacons leave by, bound to whatever port the OS gives it.
|
|
483
|
-
*
|
|
484
|
-
* SENT FROM A PORT OF ITS OWN, NOT FROM 45317. Nothing that hears a beacon
|
|
485
|
-
* reads the port it came from — the reply goes to 45317 whatever the source
|
|
486
|
-
* — and sending from the discovery port gave that port away. Measured on a
|
|
487
|
-
* Mac that is a Tailscale exit node: a deck elsewhere on the tailnet routed a
|
|
488
|
-
* beacon through it, and Tailscale's forwarder binds its end of every UDP
|
|
489
|
-
* flow to the CLIENT's source port (netstack.go, forwardUDP), idling it out
|
|
490
|
-
* after two minutes. A beacon every thirty seconds never idles, so the Mac's
|
|
491
|
-
* own deck could never bind 45317 again. From an ephemeral port, the
|
|
492
|
-
* forwarder takes an ephemeral port.
|
|
493
|
-
*/
|
|
494
|
-
const openOut = () => new Promise(resolve => {
|
|
495
|
-
let o;
|
|
496
|
-
try { o = createSocket({ type: "udp4" }); } catch { resolve(null); return; }
|
|
497
|
-
let opened = false;
|
|
498
|
-
o.on("error", err => {
|
|
499
|
-
if (opened) { onError?.("socket", err); return; }
|
|
500
|
-
opened = true;
|
|
501
|
-
try { o.close(); } catch { /* never opened */ }
|
|
502
|
-
resolve(null);
|
|
503
|
-
});
|
|
504
|
-
o.bind(0, "0.0.0.0", () => {
|
|
505
|
-
if (opened) return;
|
|
506
|
-
opened = true;
|
|
507
|
-
try { o.setBroadcast(true); } catch (err) { onError?.("broadcast", err); }
|
|
508
|
-
resolve(o);
|
|
509
|
-
});
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const start = async () => {
|
|
513
|
-
const startedIn = ++startGeneration;
|
|
514
|
-
stopped = false;
|
|
515
|
-
told = null;
|
|
516
|
-
await tryListen(startedIn);
|
|
517
|
-
if (stopped || startedIn !== startGeneration) return;
|
|
518
|
-
const opened = await openOut();
|
|
519
|
-
if (stopped || startedIn !== startGeneration) {
|
|
520
|
-
try { opened?.close(); } catch { /* already closed */ }
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
out = opened;
|
|
524
|
-
// Immediately, not on the next tick. Syncthing's rule: a deck that just
|
|
525
|
-
// came up should appear now rather than up to thirty seconds later, which
|
|
526
|
-
// is the difference between "it works" and "it seems broken" for anybody
|
|
527
|
-
// who starts two decks and watches.
|
|
528
|
-
announce();
|
|
529
|
-
timer = setInterval(announce, ANNOUNCE_MS);
|
|
530
|
-
timer.unref?.();
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
return {
|
|
534
|
-
start,
|
|
535
|
-
announce,
|
|
536
|
-
peers,
|
|
537
|
-
/** Whether this deck holds the discovery port, and why not when it does
|
|
538
|
-
* not. */
|
|
539
|
-
hearing: () => hearing,
|
|
540
|
-
deafError: () => deafError,
|
|
541
|
-
tunneled: () => tunneled,
|
|
542
|
-
stop() {
|
|
543
|
-
stopped = true;
|
|
544
|
-
startGeneration++;
|
|
545
|
-
if (timer) clearInterval(timer);
|
|
546
|
-
timer = null;
|
|
547
|
-
if (rebind) clearTimeout(rebind);
|
|
548
|
-
rebind = null;
|
|
549
|
-
hearing = false;
|
|
550
|
-
try { sock?.close(); } catch { /* already closed */ }
|
|
551
|
-
sock = null;
|
|
552
|
-
try { out?.close(); } catch { /* already closed */ }
|
|
553
|
-
out = null;
|
|
554
|
-
},
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Read newline-delimited JSON off a socket, refusing to be made to allocate.
|
|
560
|
-
*
|
|
561
|
-
* The cap is on the UNTERMINATED buffer rather than on a frame that arrived,
|
|
562
|
-
* which is the distinction that matters: a peer that sends a megabyte with no
|
|
563
|
-
* newline in it is not sending a large frame, it is sending nothing at all,
|
|
564
|
-
* expensively. Past the cap this stops reading and hands the caller a refusal
|
|
565
|
-
* — it does not keep buffering in the hope a newline turns up.
|
|
566
|
-
*/
|
|
567
|
-
export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
|
|
568
|
-
let buf = "";
|
|
569
|
-
let dead = false;
|
|
570
|
-
return chunk => {
|
|
571
|
-
if (dead) return;
|
|
572
|
-
buf += chunk;
|
|
573
|
-
if (buf.length > max) { dead = true; buf = ""; onRefuse("frame too large"); return; }
|
|
574
|
-
let i;
|
|
575
|
-
while ((i = buf.indexOf("\n")) !== -1) {
|
|
576
|
-
const line = buf.slice(0, i);
|
|
577
|
-
buf = buf.slice(i + 1);
|
|
578
|
-
if (!line.trim()) continue;
|
|
579
|
-
let msg;
|
|
580
|
-
try { msg = JSON.parse(line); }
|
|
581
|
-
catch { dead = true; buf = ""; onRefuse("not json"); return; }
|
|
582
|
-
// `typeof [] === "object"`, so an array walks straight past the obvious
|
|
583
|
-
// check and reaches a handler that reads `msg.t` off it — undefined, and
|
|
584
|
-
// then whatever that handler does with a frame that has no type. A frame
|
|
585
|
-
// is a record; anything else is refused.
|
|
586
|
-
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
|
|
587
|
-
dead = true; buf = ""; onRefuse("not an object"); return;
|
|
588
|
-
}
|
|
589
|
-
// A HANDLER THAT THROWS ENDS ITS CONNECTION, NOT THE PROCESS. This runs
|
|
590
|
-
// inside the socket's `data` event, where nothing above catches, so a
|
|
591
|
-
// throw from a frame handler took the whole deck down — and every frame
|
|
592
|
-
// read here has come off the network before anybody is trusted. The
|
|
593
|
-
// handlers are meant never to throw; this is what holds when one does.
|
|
594
|
-
// The error goes on with the refusal, so the next one that hides here is
|
|
595
|
-
// logged with its stack rather than as two words.
|
|
596
|
-
try { onFrame(msg); }
|
|
597
|
-
catch (err) { dead = true; buf = ""; onRefuse("bad frame", err); return; }
|
|
598
|
-
if (dead) return;
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/** One line out. Kept in one place so nothing forgets the newline the reader
|
|
604
|
-
* above is waiting for. */
|
|
605
|
-
export function sendFrame(sock, obj) {
|
|
606
|
-
try { sock.write(`${JSON.stringify(obj)}\n`); } catch { /* peer went away */ }
|
|
607
|
-
}
|
|
608
|
-
|
|
609
87
|
/**
|
|
610
88
|
* The answering half.
|
|
611
89
|
*
|
|
@@ -644,8 +122,14 @@ export function createSyncServer({
|
|
|
644
122
|
* on arrival rather than queued behind a press. */
|
|
645
123
|
invite = () => null,
|
|
646
124
|
/** One was used. The caller stores the pairing and retires the invite: a
|
|
647
|
-
* token that pairs twice is a token worth stealing twice.
|
|
125
|
+
* token that pairs twice is a token worth stealing twice. Called for EVERY
|
|
126
|
+
* proof that holds, a deck already paired included — see auth (#1137). */
|
|
648
127
|
onInviteUsed,
|
|
128
|
+
/** A caller brought a proof of the invite this deck is offering, and it did
|
|
129
|
+
* not hold. The caller counts it against the invite and puts the invite
|
|
130
|
+
* away after a few — see MAX_WRONG_PROOFS. Told the caller's address and
|
|
131
|
+
* who it says it is, which is what the record of it is for. */
|
|
132
|
+
onWrongInvite,
|
|
649
133
|
/** A connection arrived, before anything about it is known — called with the
|
|
650
134
|
* address it came from and nothing else.
|
|
651
135
|
*
|
|
@@ -666,6 +150,11 @@ export function createSyncServer({
|
|
|
666
150
|
* case that slept for the real value would be thirty seconds of CI per run
|
|
667
151
|
* and would still only be checking a timer. */
|
|
668
152
|
idleMs = IDLE_MS,
|
|
153
|
+
/** How long a connection has to finish the handshake — see HANDSHAKE_MS,
|
|
154
|
+
* which is what the deck runs on. A parameter for idleMs' reason: so the
|
|
155
|
+
* suite can watch a silent caller dropped in a few hundred milliseconds
|
|
156
|
+
* rather than sitting through the real five seconds of it (#994). */
|
|
157
|
+
handshakeMs = HANDSHAKE_MS,
|
|
669
158
|
/** Whether this deck seals every frame after the handshake with a peer that
|
|
670
159
|
* says it does too — see frameChannel. On in every real deck. False makes
|
|
671
160
|
* this listener announce nothing and seal nothing, which is the wire every
|
|
@@ -732,7 +221,7 @@ export function createSyncServer({
|
|
|
732
221
|
// Carrying this deck's marks — that it seals, and that it mixes a key pair
|
|
733
222
|
// of its own into the key — inside the one field the handshake already
|
|
734
223
|
// binds. See "THE ANNOUNCEMENT RIDES INSIDE THE CHALLENGE" and EPHEMERAL
|
|
735
|
-
// in lan-
|
|
224
|
+
// in lan-wire.mjs.
|
|
736
225
|
const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
|
|
737
226
|
let theirChallenge = null;
|
|
738
227
|
/** What the key was derived over, kept so the invite proofs below are made
|
|
@@ -757,7 +246,7 @@ export function createSyncServer({
|
|
|
757
246
|
// Armed before the first byte is read, and cleared only by a completed
|
|
758
247
|
// handshake. A socket that connects and says nothing is the cheapest
|
|
759
248
|
// possible way to hold a resource, so it is also the first one closed.
|
|
760
|
-
const deadline = setTimeout(() => { if (!authed) sock.destroy(); },
|
|
249
|
+
const deadline = setTimeout(() => { if (!authed) sock.destroy(); }, handshakeMs);
|
|
761
250
|
deadline.unref?.();
|
|
762
251
|
|
|
763
252
|
const done = () => { clearTimeout(deadline); live.delete(sock); };
|
|
@@ -799,6 +288,213 @@ export function createSyncServer({
|
|
|
799
288
|
setTimeout(bye, 250).unref?.();
|
|
800
289
|
};
|
|
801
290
|
|
|
291
|
+
/** Step 1 of the four, answered: a caller's hello, checked, and this
|
|
292
|
+
* end's challenge back — with the connection's key derived first. */
|
|
293
|
+
const hello = msg => {
|
|
294
|
+
// A string of letters, digits, `.`, `_` and `-` — a `|` inside it
|
|
295
|
+
// would let the transcript come out the same on both ends while each
|
|
296
|
+
// read a different challenge. See readChallenge.
|
|
297
|
+
if (theirChallenge || !readChallenge(msg.challenge)) return refuse("bad hello");
|
|
298
|
+
const them = readPub(msg.pub);
|
|
299
|
+
// The fingerprint is a hash of the key, so a hello whose two halves
|
|
300
|
+
// disagree is not a deck with a stale field, it is somebody trying to
|
|
301
|
+
// be announced as one deck and prove they are another.
|
|
302
|
+
if (!them || them.fp !== msg.fp) return refuse("bad hello");
|
|
303
|
+
theirChallenge = msg.challenge;
|
|
304
|
+
peerFp = them.fp;
|
|
305
|
+
peerPub = them.pub;
|
|
306
|
+
// THROUGH cleanName, like the beacon and the invite. This path — the
|
|
307
|
+
// handshake — was the one that skipped it, and it is the one that
|
|
308
|
+
// feeds the pairing prompt and cfg.trusted, which lan-deck.mjs writes to
|
|
309
|
+
// prefs.json. So the only bound on the name an operator reads before
|
|
310
|
+
// pressing Accept was the 128 KB frame cap.
|
|
311
|
+
//
|
|
312
|
+
// cleanName caps at MAX_NAME (40) and collapses \s+, which includes
|
|
313
|
+
// U+00A0 — and 200 non-breaking spaces neither collapse in HTML nor
|
|
314
|
+
// offer a break opportunity, so a name of "Alice's laptop at
|
|
315
|
+
// 192.168.1.10 wants to pair" + padding pushed the REAL address out
|
|
316
|
+
// of a fixed 288px column and left a complete, plausible sentence.
|
|
317
|
+
peerName = cleanName(msg.name, "");
|
|
318
|
+
peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
|
|
319
|
+
peerNoAsk = msg.ask === false;
|
|
320
|
+
// MIXED WHEN BOTH CHALLENGES SAY SO, and only then: the two strings
|
|
321
|
+
// the proofs bind decide it, never whether a field turned up. Once
|
|
322
|
+
// both say so, a hello with no usable key is refused rather than
|
|
323
|
+
// answered the old way, because answering the old way is the
|
|
324
|
+
// downgrade. See EPHEMERAL in lan-wire.mjs.
|
|
325
|
+
//
|
|
326
|
+
// A KEY OFFERED IS A KEY ANSWERED, whatever the challenges say, so
|
|
327
|
+
// what this end sends depends only on what it was sent. When the two
|
|
328
|
+
// do not mix, ours goes out and is never used. That happens only
|
|
329
|
+
// between two decks of this version whose challenges somebody edited
|
|
330
|
+
// on the way, and there it takes the dialler on to a proof this end
|
|
331
|
+
// refuses by name — `bad proof`, as for every other edit to a
|
|
332
|
+
// challenge — rather than stopping it one message short. Made here
|
|
333
|
+
// and not when the socket opened, so a connection that never says
|
|
334
|
+
// hello costs what it always did.
|
|
335
|
+
const theirEphemeral = readEphemeral(msg.epk);
|
|
336
|
+
const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
|
|
337
|
+
if (mixing && !theirEphemeral) return refuse("bad hello");
|
|
338
|
+
let mine = theirEphemeral && mixesEphemeral(myChallenge) ? ephemeralPair() : null;
|
|
339
|
+
const epk = mine?.pub;
|
|
340
|
+
transcript = mixing
|
|
341
|
+
? handshakeTranscript(peerFp, fp, theirChallenge, myChallenge, theirEphemeral, epk)
|
|
342
|
+
: handshakeTranscript(peerFp, fp, theirChallenge, myChallenge);
|
|
343
|
+
// A key X25519 cannot use throws in here, and nothing above this
|
|
344
|
+
// handler catches it — see readPub for what that throw used to do.
|
|
345
|
+
try {
|
|
346
|
+
key = sessionKey(secret, peerPub, transcript,
|
|
347
|
+
mixing ? { role: "listener", priv: mine.priv, peer: theirEphemeral } : null);
|
|
348
|
+
} catch {
|
|
349
|
+
return refuse("bad hello");
|
|
350
|
+
} finally {
|
|
351
|
+
// The private half, let go before a byte of the reply is written.
|
|
352
|
+
mine = null;
|
|
353
|
+
}
|
|
354
|
+
sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge, ...(epk ? { epk } : {}) });
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Step 4, and the only way `authed` turns true: sealed from here when both
|
|
359
|
+
* challenges said so, the handshake deadline off, and this deck's own proof
|
|
360
|
+
* sent — with the invite's proof back when the caller held one. `ok` goes
|
|
361
|
+
* out in the clear; the frames after it are the sealed kind.
|
|
362
|
+
*/
|
|
363
|
+
const welcome = back => {
|
|
364
|
+
authed = true;
|
|
365
|
+
chan = sealedChannel();
|
|
366
|
+
clearTimeout(deadline);
|
|
367
|
+
sendFrame(sock, {
|
|
368
|
+
t: "ok", fp, name,
|
|
369
|
+
proof: proof(key, {
|
|
370
|
+
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
371
|
+
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
372
|
+
}),
|
|
373
|
+
...back,
|
|
374
|
+
});
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Step 3: the caller's proof, and then who it turns out to be — a deck
|
|
378
|
+
* somebody here accepted, one holding this deck's invite, or a refusal
|
|
379
|
+
* that says which. */
|
|
380
|
+
const auth = msg => {
|
|
381
|
+
// A key, too: a challenge on record says a `hello` arrived, not that one
|
|
382
|
+
// was accepted. Unreachable after the guard at the top of the frame
|
|
383
|
+
// handler, and stated anyway, because it is the rule `proof` below
|
|
384
|
+
// depends on.
|
|
385
|
+
if (msg.t !== "auth" || !theirChallenge || !key) return refuse("expected auth");
|
|
386
|
+
const want = proof(key, {
|
|
387
|
+
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
388
|
+
fromFp: peerFp, toFp: fp, direction: "hello",
|
|
389
|
+
});
|
|
390
|
+
// A recording of a previous exchange fails here, because `myChallenge`
|
|
391
|
+
// was made when this socket opened and has never been sent before.
|
|
392
|
+
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
393
|
+
|
|
394
|
+
// WHO IS THIS, and it is the only question left. The handshake proves
|
|
395
|
+
// they hold the key they claimed; the trusted list says whether anybody
|
|
396
|
+
// here ever agreed to talk to it.
|
|
397
|
+
const known = trustedPeer(trusted(), peerFp);
|
|
398
|
+
if (known && known.pub !== peerPub) {
|
|
399
|
+
// The fingerprint we pinned, presented with a different key. 48 bits
|
|
400
|
+
// is far past accident, so this is somebody wearing a paired deck's
|
|
401
|
+
// name — refused loudly rather than quietly re-pinned.
|
|
402
|
+
return refuse("impostor");
|
|
403
|
+
}
|
|
404
|
+
// AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
|
|
405
|
+
// holds a token the owner of this machine copied and sent, which is the
|
|
406
|
+
// same decision the accept button is — made earlier, and made once.
|
|
407
|
+
//
|
|
408
|
+
// The proof is over the transcript, so it is worth nothing to somebody
|
|
409
|
+
// who recorded an earlier exchange, and the code itself never travels.
|
|
410
|
+
//
|
|
411
|
+
// ASKED BEFORE "DO I KNOW THIS DECK", because the two questions are
|
|
412
|
+
// independent and the answer to this one is owed to the caller either
|
|
413
|
+
// way. A caller that sent a code is waiting to be shown one back, and a
|
|
414
|
+
// deck it has ALREADY paired with is not exempt from that — somebody
|
|
415
|
+
// pasting a token into a deck that happens to be paired already would
|
|
416
|
+
// otherwise be told the minter does not hold its own invite.
|
|
417
|
+
// `offer` rather than `live`, which is what this used to be called: the
|
|
418
|
+
// socket table one scope out is also `live`, and widening this block
|
|
419
|
+
// widened the shadow with it.
|
|
420
|
+
const offer = invite();
|
|
421
|
+
const brought = typeof msg.invite === "string";
|
|
422
|
+
// Over the transcript the key came from — the ephemeral keys in it
|
|
423
|
+
// when the two mix — which is the string the dialler proved over too.
|
|
424
|
+
const heldInvite = !!offer && brought && proofOk(inviteProof(offer.code, transcript), msg.invite);
|
|
425
|
+
// A PROOF THAT DID NOT HOLD, against an invite that is live (#1137).
|
|
426
|
+
// Counted against that invite, which is put away after a few, and
|
|
427
|
+
// refused by name rather than read as a caller that brought nothing:
|
|
428
|
+
// that used to make a wrong token a pairing request here, and on the
|
|
429
|
+
// caller's screen "waiting for them to say yes" about a question
|
|
430
|
+
// nobody was going to be asked. With no invite live there is nothing
|
|
431
|
+
// to count against, and the caller is answered as it always was.
|
|
432
|
+
if (offer && brought && !heldInvite) {
|
|
433
|
+
onWrongInvite?.({ fp: peerFp, name: peerName, addr: from(sock) });
|
|
434
|
+
return refuse("wrong invite");
|
|
435
|
+
}
|
|
436
|
+
// AND THE CODE, BACK. The session proof says "I hold the private half
|
|
437
|
+
// of the key I just showed you", which anything with a socket can say.
|
|
438
|
+
// This says "I am the deck whose owner minted that token", which only
|
|
439
|
+
// the minter can — and a caller joining on an invite has no pin to
|
|
440
|
+
// check against, so it is the only thing standing between an invite
|
|
441
|
+
// address and whoever else is reachable there.
|
|
442
|
+
const back = heldInvite ? { inviteProof: inviteProofBack(offer.code, transcript) } : {};
|
|
443
|
+
// SPENT BY ANY PROOF THAT HOLDS, known deck or not (#1137). This was
|
|
444
|
+
// asked only on the stranger's path below, so a deck already on the list
|
|
445
|
+
// was shown the proof back and the token stayed live for the rest of its
|
|
446
|
+
// ten minutes — and "already on the list" includes a deck whose first
|
|
447
|
+
// pairing dropped out after this very proof, which is exactly the deck
|
|
448
|
+
// that comes back with a fresh invite. Whether the caller was known
|
|
449
|
+
// decides nothing about whether the token has been used; it has. So
|
|
450
|
+
// there is nothing to press, either: the deck is pinned here.
|
|
451
|
+
if (heldInvite) {
|
|
452
|
+
onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort, addr: from(sock) });
|
|
453
|
+
welcome(back);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (!known) {
|
|
457
|
+
// A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
|
|
458
|
+
// It is not asked again here, and — the half a held refusal cannot
|
|
459
|
+
// do — the deck that asked is told, so its own panel can stop saying
|
|
460
|
+
// "waiting" about a question that has been answered.
|
|
461
|
+
if (declined(peerFp)) return refuse("declined");
|
|
462
|
+
// AN INVITE-ONLY DECK, and this caller holds no live invite of it (a
|
|
463
|
+
// caller that did was paired above, and one that got it wrong was
|
|
464
|
+
// refused). Answered rather than queued: the engine records no
|
|
465
|
+
// request in this mode, so "pending" would leave the other deck's
|
|
466
|
+
// panel saying "waiting for them to say yes" about a question nobody
|
|
467
|
+
// here will ever see. After "declined", which is the more specific
|
|
468
|
+
// answer about this one deck.
|
|
469
|
+
if (inviteOnly()) return refuse("invite only");
|
|
470
|
+
// A CALLER THAT IS NOT ASKING. An invite-only deck still dials the
|
|
471
|
+
// addresses it already had, because an invite-paired deck is one of
|
|
472
|
+
// them; one that turns out not to know it must not become a request
|
|
473
|
+
// here — with the accept switch on, that request would have pinned a
|
|
474
|
+
// deck whose owner said it pairs only by invite.
|
|
475
|
+
if (peerNoAsk) return refuse("not asking");
|
|
476
|
+
|
|
477
|
+
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
478
|
+
// a port scan, and it told us a name and an address a person can
|
|
479
|
+
// recognise. That is a row with an accept on it, and nothing else
|
|
480
|
+
// happens until somebody presses it.
|
|
481
|
+
onPending?.({
|
|
482
|
+
fp: peerFp, pub: peerPub, name: peerName,
|
|
483
|
+
addr: from(sock),
|
|
484
|
+
// Where it LISTENS, from the hello — not this socket's remote port,
|
|
485
|
+
// which is ephemeral. This is what lets an accept dial back.
|
|
486
|
+
port: peerPort,
|
|
487
|
+
});
|
|
488
|
+
return refuse("pending");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// And ours, so the caller knows it reached the deck it pinned rather
|
|
492
|
+
// than something standing in the way of one. No invite on this path: a
|
|
493
|
+
// caller that held one was answered above, and a deck already on this
|
|
494
|
+
// list that holds no live one is simply a paired deck calling.
|
|
495
|
+
welcome({});
|
|
496
|
+
};
|
|
497
|
+
|
|
802
498
|
sock.on("data", frameReader(msg => {
|
|
803
499
|
// NOTHING AFTER A REFUSAL. The reader hands over every frame in a chunk,
|
|
804
500
|
// and `refuse` destroys the socket without stopping it. So a `hello`
|
|
@@ -827,188 +523,8 @@ export function createSyncServer({
|
|
|
827
523
|
// they buy is a connection key that is gone once both ends have let go
|
|
828
524
|
// of the private halves, whoever takes either long-term key later —
|
|
829
525
|
// see sessionKey.
|
|
830
|
-
if (msg.t === "hello")
|
|
831
|
-
|
|
832
|
-
// would let the transcript come out the same on both ends while each
|
|
833
|
-
// read a different challenge. See readChallenge.
|
|
834
|
-
if (theirChallenge || !readChallenge(msg.challenge)) return refuse("bad hello");
|
|
835
|
-
const them = readPub(msg.pub);
|
|
836
|
-
// The fingerprint is a hash of the key, so a hello whose two halves
|
|
837
|
-
// disagree is not a deck with a stale field, it is somebody trying to
|
|
838
|
-
// be announced as one deck and prove they are another.
|
|
839
|
-
if (!them || them.fp !== msg.fp) return refuse("bad hello");
|
|
840
|
-
theirChallenge = msg.challenge;
|
|
841
|
-
peerFp = them.fp;
|
|
842
|
-
peerPub = them.pub;
|
|
843
|
-
// THROUGH cleanName, like the beacon and the invite. This path — the
|
|
844
|
-
// handshake — was the one that skipped it, and it is the one that
|
|
845
|
-
// feeds the pairing prompt and cfg.trusted, which index.mjs writes to
|
|
846
|
-
// prefs.json. So the only bound on the name an operator reads before
|
|
847
|
-
// pressing Accept was the 128 KB frame cap.
|
|
848
|
-
//
|
|
849
|
-
// cleanName caps at MAX_NAME (40) and collapses \s+, which includes
|
|
850
|
-
// U+00A0 — and 200 non-breaking spaces neither collapse in HTML nor
|
|
851
|
-
// offer a break opportunity, so a name of "Alice's laptop at
|
|
852
|
-
// 192.168.1.10 wants to pair" + padding pushed the REAL address out
|
|
853
|
-
// of a fixed 288px column and left a complete, plausible sentence.
|
|
854
|
-
peerName = cleanName(msg.name, "");
|
|
855
|
-
peerPort = Number.isInteger(msg.port) && msg.port > 0 && msg.port < 65_536 ? msg.port : null;
|
|
856
|
-
peerNoAsk = msg.ask === false;
|
|
857
|
-
// MIXED WHEN BOTH CHALLENGES SAY SO, and only then: the two strings
|
|
858
|
-
// the proofs bind decide it, never whether a field turned up. Once
|
|
859
|
-
// both say so, a hello with no usable key is refused rather than
|
|
860
|
-
// answered the old way, because answering the old way is the
|
|
861
|
-
// downgrade. See EPHEMERAL in lan-sync.mjs.
|
|
862
|
-
//
|
|
863
|
-
// A KEY OFFERED IS A KEY ANSWERED, whatever the challenges say, so
|
|
864
|
-
// what this end sends depends only on what it was sent. When the two
|
|
865
|
-
// do not mix, ours goes out and is never used. That happens only
|
|
866
|
-
// between two decks of this version whose challenges somebody edited
|
|
867
|
-
// on the way, and there it takes the dialler on to a proof this end
|
|
868
|
-
// refuses by name — `bad proof`, as for every other edit to a
|
|
869
|
-
// challenge — rather than stopping it one message short. Made here
|
|
870
|
-
// and not when the socket opened, so a connection that never says
|
|
871
|
-
// hello costs what it always did.
|
|
872
|
-
const theirEphemeral = readEphemeral(msg.epk);
|
|
873
|
-
const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
|
|
874
|
-
if (mixing && !theirEphemeral) return refuse("bad hello");
|
|
875
|
-
let mine = theirEphemeral && mixesEphemeral(myChallenge) ? ephemeralPair() : null;
|
|
876
|
-
const epk = mine?.pub;
|
|
877
|
-
transcript = mixing
|
|
878
|
-
? handshakeTranscript(peerFp, fp, theirChallenge, myChallenge, theirEphemeral, epk)
|
|
879
|
-
: handshakeTranscript(peerFp, fp, theirChallenge, myChallenge);
|
|
880
|
-
// A key X25519 cannot use throws in here, and nothing above this
|
|
881
|
-
// handler catches it — see readPub for what that throw used to do.
|
|
882
|
-
try {
|
|
883
|
-
key = sessionKey(secret, peerPub, transcript,
|
|
884
|
-
mixing ? { role: "listener", priv: mine.priv, peer: theirEphemeral } : null);
|
|
885
|
-
} catch {
|
|
886
|
-
return refuse("bad hello");
|
|
887
|
-
} finally {
|
|
888
|
-
// The private half, let go before a byte of the reply is written.
|
|
889
|
-
mine = null;
|
|
890
|
-
}
|
|
891
|
-
sendFrame(sock, { t: "challenge", fp, pub, name, challenge: myChallenge, ...(epk ? { epk } : {}) });
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
// A key, too: a challenge on record says a `hello` arrived, not that one
|
|
895
|
-
// was accepted. Unreachable after the guard at the top of this handler,
|
|
896
|
-
// and stated anyway, because it is the rule `proof` below depends on.
|
|
897
|
-
if (msg.t !== "auth" || !theirChallenge || !key) return refuse("expected auth");
|
|
898
|
-
const want = proof(key, {
|
|
899
|
-
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
900
|
-
fromFp: peerFp, toFp: fp, direction: "hello",
|
|
901
|
-
});
|
|
902
|
-
// A recording of a previous exchange fails here, because `myChallenge`
|
|
903
|
-
// was made when this socket opened and has never been sent before.
|
|
904
|
-
if (!proofOk(want, msg.proof)) return refuse("bad proof");
|
|
905
|
-
|
|
906
|
-
// WHO IS THIS, and it is the only question left. The handshake proves
|
|
907
|
-
// they hold the key they claimed; the trusted list says whether anybody
|
|
908
|
-
// here ever agreed to talk to it.
|
|
909
|
-
const known = trustedPeer(trusted(), peerFp);
|
|
910
|
-
if (known && known.pub !== peerPub) {
|
|
911
|
-
// The fingerprint we pinned, presented with a different key. 48 bits
|
|
912
|
-
// is far past accident, so this is somebody wearing a paired deck's
|
|
913
|
-
// name — refused loudly rather than quietly re-pinned.
|
|
914
|
-
return refuse("impostor");
|
|
915
|
-
}
|
|
916
|
-
// AN INVITE THIS DECK HANDED OUT, PRESENTED BACK. Whoever is calling
|
|
917
|
-
// holds a token the owner of this machine copied and sent, which is the
|
|
918
|
-
// same decision the accept button is — made earlier, and made once.
|
|
919
|
-
//
|
|
920
|
-
// The proof is over the transcript, so it is worth nothing to somebody
|
|
921
|
-
// who recorded an earlier exchange, and the code itself never travels.
|
|
922
|
-
//
|
|
923
|
-
// ASKED BEFORE "DO I KNOW THIS DECK", because the two questions are
|
|
924
|
-
// independent and the answer to this one is owed to the caller either
|
|
925
|
-
// way. A caller that sent a code is waiting to be shown one back, and a
|
|
926
|
-
// deck it has ALREADY paired with is not exempt from that — somebody
|
|
927
|
-
// pasting a token into a deck that happens to be paired already would
|
|
928
|
-
// otherwise be told the minter does not hold its own invite.
|
|
929
|
-
// `offer` rather than `live`, which is what this used to be called: the
|
|
930
|
-
// socket table one scope out is also `live`, and widening this block
|
|
931
|
-
// widened the shadow with it.
|
|
932
|
-
const offer = invite();
|
|
933
|
-
// Over the transcript the key came from — the ephemeral keys in it
|
|
934
|
-
// when the two mix — which is the string the dialler proved over too.
|
|
935
|
-
const heldInvite = !!offer && typeof msg.invite === "string"
|
|
936
|
-
&& proofOk(inviteProof(offer.code, transcript), msg.invite);
|
|
937
|
-
// AND THE CODE, BACK. The session proof says "I hold the private half
|
|
938
|
-
// of the key I just showed you", which anything with a socket can say.
|
|
939
|
-
// This says "I am the deck whose owner minted that token", which only
|
|
940
|
-
// the minter can — and a caller joining on an invite has no pin to
|
|
941
|
-
// check against, so it is the only thing standing between an invite
|
|
942
|
-
// address and whoever else is reachable there.
|
|
943
|
-
const back = heldInvite ? { inviteProof: inviteProofBack(offer.code, transcript) } : {};
|
|
944
|
-
if (!known) {
|
|
945
|
-
if (heldInvite) {
|
|
946
|
-
// So there is nothing to press: the deck is pinned here.
|
|
947
|
-
onInviteUsed?.({ fp: peerFp, pub: peerPub, name: peerName, port: peerPort,
|
|
948
|
-
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "" });
|
|
949
|
-
authed = true;
|
|
950
|
-
chan = sealedChannel();
|
|
951
|
-
clearTimeout(deadline);
|
|
952
|
-
sendFrame(sock, {
|
|
953
|
-
t: "ok", fp, name,
|
|
954
|
-
proof: proof(key, {
|
|
955
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
956
|
-
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
957
|
-
}),
|
|
958
|
-
...back,
|
|
959
|
-
});
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
// A DECK THIS ONE'S OWNER ALREADY ANSWERED, and the answer was no.
|
|
963
|
-
// It is not asked again here, and — the half a held refusal cannot
|
|
964
|
-
// do — the deck that asked is told, so its own panel can stop saying
|
|
965
|
-
// "waiting" about a question that has been answered.
|
|
966
|
-
if (declined(peerFp)) return refuse("declined");
|
|
967
|
-
// AN INVITE-ONLY DECK, and this caller brought none (a caller that
|
|
968
|
-
// did was paired above). Answered rather than queued: the engine
|
|
969
|
-
// records no request in this mode, so "pending" would leave the other
|
|
970
|
-
// deck's panel saying "waiting for them to say yes" about a question
|
|
971
|
-
// nobody here will ever see. After "declined", which is the more
|
|
972
|
-
// specific answer about this one deck.
|
|
973
|
-
if (inviteOnly()) return refuse("invite only");
|
|
974
|
-
// A CALLER THAT IS NOT ASKING. An invite-only deck still dials the
|
|
975
|
-
// addresses it already had, because an invite-paired deck is one of
|
|
976
|
-
// them; one that turns out not to know it must not become a request
|
|
977
|
-
// here — with the accept switch on, that request would have pinned a
|
|
978
|
-
// deck whose owner said it pairs only by invite.
|
|
979
|
-
if (peerNoAsk) return refuse("not asking");
|
|
980
|
-
|
|
981
|
-
// A REAL DECK WE HAVE NOT MET. It finished a handshake, so it is not
|
|
982
|
-
// a port scan, and it told us a name and an address a person can
|
|
983
|
-
// recognise. That is a row with an accept on it, and nothing else
|
|
984
|
-
// happens until somebody presses it.
|
|
985
|
-
onPending?.({
|
|
986
|
-
fp: peerFp, pub: peerPub, name: peerName,
|
|
987
|
-
addr: sock.remoteAddress?.replace(/^::ffff:/, "") ?? "",
|
|
988
|
-
// Where it LISTENS, from the hello — not this socket's remote port,
|
|
989
|
-
// which is ephemeral. This is what lets an accept dial back.
|
|
990
|
-
port: peerPort,
|
|
991
|
-
});
|
|
992
|
-
return refuse("pending");
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
authed = true;
|
|
996
|
-
chan = sealedChannel();
|
|
997
|
-
clearTimeout(deadline);
|
|
998
|
-
// And ours, so the caller knows it reached the deck it pinned rather
|
|
999
|
-
// than something standing in the way of one. Plus the invite, when one
|
|
1000
|
-
// was presented and held: a deck already on this list is not a reason
|
|
1001
|
-
// to leave a caller's question unanswered. Nothing is retired on this
|
|
1002
|
-
// path — nobody was paired, because they already were.
|
|
1003
|
-
sendFrame(sock, {
|
|
1004
|
-
t: "ok", fp, name,
|
|
1005
|
-
proof: proof(key, {
|
|
1006
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
1007
|
-
fromFp: fp, toFp: peerFp, direction: "reply",
|
|
1008
|
-
}),
|
|
1009
|
-
...back,
|
|
1010
|
-
});
|
|
1011
|
-
return;
|
|
526
|
+
if (msg.t === "hello") return hello(msg);
|
|
527
|
+
return auth(msg);
|
|
1012
528
|
}
|
|
1013
529
|
// SEALED FROM HERE WHEN BOTH ENDS SAID SO, AND ONLY SEALED. A frame that
|
|
1014
530
|
// does not open is refused and the socket goes with it — see
|
|
@@ -1105,201 +621,3 @@ export function createSyncServer({
|
|
|
1105
621
|
},
|
|
1106
622
|
};
|
|
1107
623
|
}
|
|
1108
|
-
|
|
1109
|
-
/**
|
|
1110
|
-
* The calling half: connect, prove, be proved to, then talk.
|
|
1111
|
-
*
|
|
1112
|
-
* BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
|
|
1113
|
-
* handshake where only the caller proves itself stops a stranger reading a
|
|
1114
|
-
* manifest and stops nothing else — a stranger can still stand up a listener on
|
|
1115
|
-
* the announced port, wait for a real deck to dial it, and be handed whatever
|
|
1116
|
-
* that deck was going to say. So this checks the reply with the same care the
|
|
1117
|
-
* server checks the hello, and gives up if it does not hold.
|
|
1118
|
-
*/
|
|
1119
|
-
export function connectToPeer({
|
|
1120
|
-
host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
|
|
1121
|
-
/** False when this deck is reaching an address it already had WITHOUT asking
|
|
1122
|
-
* to pair — an invite-only deck's round. Sent only when false, so every other
|
|
1123
|
-
* hello is byte-for-byte what it was, and a deck that predates the field
|
|
1124
|
-
* reads past it the way it reads past `epk`. */
|
|
1125
|
-
ask = true,
|
|
1126
|
-
/** The public key we pinned for this deck the first time, or null for a deck
|
|
1127
|
-
* we are meeting — an address somebody typed. */
|
|
1128
|
-
expectPub = null,
|
|
1129
|
-
/** Whether the deck that minted `code` said it will prove it holds one too.
|
|
1130
|
-
* From the token's own `pb`, which is the only way to tell a deck that will
|
|
1131
|
-
* not from a deck that cannot — see mintInvite. False leaves this path
|
|
1132
|
-
* exactly as it shipped, for a token minted by an older deck. */
|
|
1133
|
-
inviteProvesBack = false,
|
|
1134
|
-
/** Whether this deck seals every frame after the handshake with a deck that
|
|
1135
|
-
* says it does too — see frameChannel. False announces nothing and seals
|
|
1136
|
-
* nothing, exactly as every deck before #810 dials; only the suite asks. */
|
|
1137
|
-
sealFrames = true,
|
|
1138
|
-
/** Whether this deck mixes a key pair made for the connection into its key,
|
|
1139
|
-
* with a deck that says it does too — see sessionKey. False dials exactly
|
|
1140
|
-
* as a deck of #810's version does; only the suite asks. */
|
|
1141
|
-
ephemeral = true,
|
|
1142
|
-
}) {
|
|
1143
|
-
return new Promise((resolve, reject) => {
|
|
1144
|
-
const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
|
|
1145
|
-
// Made before the other deck is heard from, because the public half goes
|
|
1146
|
-
// in the hello. Let go of once the key is derived — unused, when the deck
|
|
1147
|
-
// that answers turns out not to mix — and on every way out below.
|
|
1148
|
-
let mine = mixesEphemeral(myChallenge) ? ephemeralPair() : null;
|
|
1149
|
-
const myEpk = mine?.pub;
|
|
1150
|
-
const sock = net.createConnection({ host, port });
|
|
1151
|
-
sock.setEncoding("utf8");
|
|
1152
|
-
let settled = false;
|
|
1153
|
-
const fail = err => {
|
|
1154
|
-
if (settled) return;
|
|
1155
|
-
settled = true;
|
|
1156
|
-
mine = null;
|
|
1157
|
-
sock.destroy();
|
|
1158
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1159
|
-
};
|
|
1160
|
-
const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
|
|
1161
|
-
timer.unref?.();
|
|
1162
|
-
|
|
1163
|
-
sock.on("error", fail);
|
|
1164
|
-
sock.on("close", () => fail(new Error("peer closed the connection")));
|
|
1165
|
-
sock.on("connect", () => {
|
|
1166
|
-
// No proof in the hello: the caller cannot cover a challenge it has not
|
|
1167
|
-
// been given, and a proof over an empty one would be a proof that means
|
|
1168
|
-
// nothing. It goes in message three.
|
|
1169
|
-
// `port` is where WE listen, which is not the port this socket came from
|
|
1170
|
-
// — that one is ephemeral and useless to dial. Without it a deck can
|
|
1171
|
-
// accept an incoming request and still have no way to reach back, so the
|
|
1172
|
-
// pairing is mutual on paper and one-way in fact.
|
|
1173
|
-
// `epk` is this connection's key pair, offered: a deck from before #1120
|
|
1174
|
-
// reads past a field it does not know, and one of this version answers.
|
|
1175
|
-
sendFrame(sock, {
|
|
1176
|
-
t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
|
|
1177
|
-
...(ask === false ? { ask: false } : {}),
|
|
1178
|
-
});
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
let theirChallenge = null;
|
|
1182
|
-
let theirFp = null;
|
|
1183
|
-
let theirPub = null;
|
|
1184
|
-
let key = null;
|
|
1185
|
-
/** What the key was derived over; the invite proofs are made over it too. */
|
|
1186
|
-
let transcript = null;
|
|
1187
|
-
sock.on("data", frameReader(msg => {
|
|
1188
|
-
if (settled) return;
|
|
1189
|
-
// A deck that heard us and said no. Each reason is a different problem
|
|
1190
|
-
// with a different fix, and until this frame existed they were all one
|
|
1191
|
-
// silent close that read as a firewall. See REFUSALS.
|
|
1192
|
-
if (msg.t === "no") {
|
|
1193
|
-
// `Object.hasOwn`, and here more than anywhere: `msg.why` is a field in
|
|
1194
|
-
// a frame written by the OTHER MACHINE, which is the case admin-failure
|
|
1195
|
-
// states the rule for (#474). Every member of Object.prototype answers
|
|
1196
|
-
// a plain bracket read with an inherited value that is neither nullish
|
|
1197
|
-
// nor falsy, so `?? "the other deck refused this handshake"` never
|
|
1198
|
-
// fired for one — `{...}["constructor"]` is the Object function itself,
|
|
1199
|
-
// and `new Error(Object).message` is the string "function Object() {
|
|
1200
|
-
// [native code] }". lan-engine files that as `lastRound.error` and the
|
|
1201
|
-
// LAN panel prints it verbatim, so a peer chose what appeared in the
|
|
1202
|
-
// user's interface. Asking whether the map has a ROW is the question
|
|
1203
|
-
// this read was always trying to ask; no real reason moves.
|
|
1204
|
-
return fail(new Error(Object.hasOwn(REFUSALS, msg.why) ? REFUSALS[msg.why]
|
|
1205
|
-
: "the other deck refused this handshake"));
|
|
1206
|
-
}
|
|
1207
|
-
if (msg.t === "challenge") {
|
|
1208
|
-
if (theirFp || !readChallenge(msg.challenge)) return fail(new Error("bad challenge"));
|
|
1209
|
-
const them = readPub(msg.pub);
|
|
1210
|
-
if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
|
|
1211
|
-
// THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
|
|
1212
|
-
// this key and no other; a key that does not match is not a peer whose
|
|
1213
|
-
// details changed, it is a different machine at the same address.
|
|
1214
|
-
if (expectPub && expectPub !== them.pub) {
|
|
1215
|
-
return fail(new Error("a different deck is answering at that address"));
|
|
1216
|
-
}
|
|
1217
|
-
theirChallenge = msg.challenge;
|
|
1218
|
-
theirFp = them.fp;
|
|
1219
|
-
theirPub = them.pub;
|
|
1220
|
-
// Mixed when both challenges say so, as at the listener, and then the
|
|
1221
|
-
// listener's key is required: a deck of this version that says it
|
|
1222
|
-
// mixes and sends no key is not an older deck, it is a deck whose key
|
|
1223
|
-
// was taken out on the way. Refused, and never read the old way.
|
|
1224
|
-
const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
|
|
1225
|
-
const theirEphemeral = mixing ? readEphemeral(msg.epk) : null;
|
|
1226
|
-
if (mixing && !theirEphemeral) return fail(new Error("bad challenge"));
|
|
1227
|
-
transcript = mixing
|
|
1228
|
-
? handshakeTranscript(fp, theirFp, myChallenge, theirChallenge, myEpk, theirEphemeral)
|
|
1229
|
-
: handshakeTranscript(fp, theirFp, myChallenge, theirChallenge);
|
|
1230
|
-
try {
|
|
1231
|
-
key = sessionKey(secret, theirPub, transcript,
|
|
1232
|
-
mixing ? { role: "caller", priv: mine.priv, peer: theirEphemeral } : null);
|
|
1233
|
-
} catch {
|
|
1234
|
-
return fail(new Error("bad challenge"));
|
|
1235
|
-
} finally {
|
|
1236
|
-
// Used or not, the private half goes now.
|
|
1237
|
-
mine = null;
|
|
1238
|
-
}
|
|
1239
|
-
sendFrame(sock, {
|
|
1240
|
-
t: "auth",
|
|
1241
|
-
proof: proof(key, {
|
|
1242
|
-
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
1243
|
-
fromFp: fp, toFp: theirFp, direction: "hello",
|
|
1244
|
-
}),
|
|
1245
|
-
// Only when joining on an invite. Sent in the same frame as the
|
|
1246
|
-
// session proof so a deck that holds a token is paired in one round
|
|
1247
|
-
// trip rather than being queued behind somebody else's press.
|
|
1248
|
-
...(code ? { invite: inviteProof(code, transcript) } : {}),
|
|
1249
|
-
});
|
|
1250
|
-
return;
|
|
1251
|
-
}
|
|
1252
|
-
// The same deck that gave us the challenge, or nothing: a reply naming a
|
|
1253
|
-
// different fingerprint is a second party in the middle of this.
|
|
1254
|
-
//
|
|
1255
|
-
// AND A CHALLENGE FIRST. `theirFp` is null until one is accepted, so an
|
|
1256
|
-
// `ok` sent as the very first frame with `fp: null` matched it — null is
|
|
1257
|
-
// null — and walked into `proof` with no key, which throws. Before #1146
|
|
1258
|
-
// that throw ended the deck, and this end dials whatever address a beacon
|
|
1259
|
-
// announces; since #1146 the reader catches it as "bad frame". Neither is
|
|
1260
|
-
// the answer: an `ok` before a challenge is out of order, and says so.
|
|
1261
|
-
if (msg.t !== "ok" || !theirFp || !key || msg.fp !== theirFp) return fail(new Error("expected ok"));
|
|
1262
|
-
const want = proof(key, {
|
|
1263
|
-
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
1264
|
-
fromFp: theirFp, toFp: fp, direction: "reply",
|
|
1265
|
-
});
|
|
1266
|
-
if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
|
|
1267
|
-
// AND THAT IT IS THE DECK THE INVITE NAMED. The proof above is about a
|
|
1268
|
-
// key the responder chose a moment ago; this one is about a code it had
|
|
1269
|
-
// to have been given. `join` has no pin to pass, so `expectPub` above is
|
|
1270
|
-
// null on this path and this is the only check that distinguishes the
|
|
1271
|
-
// deck whose owner minted the token from whatever else is reachable at
|
|
1272
|
-
// one of the ten addresses the token happens to carry.
|
|
1273
|
-
//
|
|
1274
|
-
// The caller giving up here is not the end of the attempt: `join` walks
|
|
1275
|
-
// the rest of the list, so a deck answering at a stale or borrowed
|
|
1276
|
-
// address costs one failed address instead of winning the whole token.
|
|
1277
|
-
if (code && inviteProvesBack) {
|
|
1278
|
-
const back = inviteProofBack(code, transcript);
|
|
1279
|
-
if (!proofOk(back, msg.inviteProof)) {
|
|
1280
|
-
return fail(new Error("that deck does not hold the invite"));
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
// Sealed from here when both challenges said so. The listener decided
|
|
1284
|
-
// the same at the same moment from the same two strings, and the proof
|
|
1285
|
-
// just checked is what says it saw the same two. See frameChannel.
|
|
1286
|
-
const chan = sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "caller") : null;
|
|
1287
|
-
settled = true;
|
|
1288
|
-
clearTimeout(timer);
|
|
1289
|
-
sock.removeAllListeners("close");
|
|
1290
|
-
resolve({
|
|
1291
|
-
sock, key, sealed: !!chan,
|
|
1292
|
-
peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
|
|
1293
|
-
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
1294
|
-
/** What a frame from the other end says — or null on a sealed
|
|
1295
|
-
* connection when it does not open, which ends the connection: every
|
|
1296
|
-
* frame after it would fail too. On a connection to a deck from before
|
|
1297
|
-
* #810, a plain frame passes through as it arrived. */
|
|
1298
|
-
read: frame => (chan ? chan.unwrap(frame) : frame),
|
|
1299
|
-
});
|
|
1300
|
-
// A refusal from the reader, with the thrown error as its cause when there
|
|
1301
|
-
// was one: the message is the one it always was, and whatever logs the
|
|
1302
|
-
// rejection can reach what actually threw.
|
|
1303
|
-
}, (why, cause) => fail(cause ? new Error(why, { cause }) : new Error(why))));
|
|
1304
|
-
});
|
|
1305
|
-
}
|