ccdeck 3.29.11 → 3.31.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 +4 -3
- 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-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -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 +7 -3
- 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 +2 -2
- package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -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 +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
// The thing that actually heals an account: the beacon, the listener and
|
|
2
2
|
// claude-swap, wired together.
|
|
3
3
|
//
|
|
4
|
-
// The
|
|
5
|
-
//
|
|
6
|
-
// is left here is the part that has to touch
|
|
7
|
-
//
|
|
4
|
+
// The files under this one hold everything that can be reasoned about without
|
|
5
|
+
// a network — lan-sync.mjs and lan-invite.mjs decide, lan-beacon.mjs and
|
|
6
|
+
// lan-socket.mjs carry — and what is left here is the part that has to touch
|
|
7
|
+
// the store. The engine's own state lives beside it, each piece behind named
|
|
8
|
+
// operations createEngine calls: the decks waiting on somebody here and when a
|
|
9
|
+
// switch answers them (lan-requests.mjs), the addresses it dials and what
|
|
10
|
+
// answered at each (lan-dials.mjs), what a manifest says and what is kept of
|
|
11
|
+
// one heard (lan-manifest.mjs), the store's rows in the rules' shape
|
|
12
|
+
// (lan-accounts.mjs), the line every round waits in (lan-turns.mjs), the
|
|
13
|
+
// clock that asks for the next one (lan-round-timer.mjs) and what the last one
|
|
14
|
+
// did (lan-round-record.mjs), who has called this deck (lan-inbound.mjs), the
|
|
15
|
+
// invite on offer (lan-invite-offer.mjs), when the tailnet is read
|
|
16
|
+
// (lan-tailnet-poll.mjs), and what a deck that cannot hear says
|
|
17
|
+
// (lan-hearing.mjs). What it answers a paired deck that asks lives in
|
|
18
|
+
// lan-serve.mjs; the asking — a round, and joining on an invite — is here.
|
|
8
19
|
//
|
|
9
20
|
// WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
|
|
10
21
|
//
|
|
11
|
-
// 1. it hears a beacon from a deck
|
|
12
|
-
// 2. it dials that deck's sync port and
|
|
22
|
+
// 1. it hears a beacon from a deck somebody here has accepted
|
|
23
|
+
// 2. it dials that deck's sync port and each side proves the key the other
|
|
24
|
+
// pinned for it
|
|
13
25
|
// 3. it asks for a manifest: which accounts, and does each one work THERE
|
|
14
26
|
// 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
|
|
15
27
|
// 5. it asks for that one account, with a fresh proof naming it
|
|
@@ -27,34 +39,33 @@
|
|
|
27
39
|
// something in it, which is when an account is actually broken. A deck whose
|
|
28
40
|
// accounts all work talks to its peers every minute and never asks for
|
|
29
41
|
// anything.
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
addTrusted, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, stillListed, transferChallenge,
|
|
44
|
+
trustedPeer,
|
|
45
|
+
} from "./lan-sync.mjs";
|
|
46
|
+
import { mintInvite, readInvite } from "./lan-invite.mjs";
|
|
47
|
+
import { createInviteOffer } from "./lan-invite-offer.mjs";
|
|
48
|
+
import { syncAccounts } from "./lan-accounts.mjs";
|
|
49
|
+
import { createBeacon } from "./lan-beacon.mjs";
|
|
50
|
+
import { connectToPeer, createSyncServer, MAX_FRAME_BYTES } from "./lan-socket.mjs";
|
|
51
|
+
import { createTurns } from "./lan-turns.mjs";
|
|
52
|
+
import { ASKING_MS, createRoundTimer, SYNC_MS } from "./lan-round-timer.mjs";
|
|
53
|
+
import { createRoundRecord } from "./lan-round-record.mjs";
|
|
54
|
+
import { beaconTargets, routeOf, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
|
|
55
|
+
import { createTailnetPoll } from "./lan-tailnet-poll.mjs";
|
|
56
|
+
import { createHearing } from "./lan-hearing.mjs";
|
|
57
|
+
import { anotherMachine, createInbound } from "./lan-inbound.mjs";
|
|
58
|
+
import { createServe } from "./lan-serve.mjs";
|
|
59
|
+
import { createDials, MAX_AUTO_PEERS } from "./lan-dials.mjs";
|
|
60
|
+
import { createManifests } from "./lan-manifest.mjs";
|
|
61
|
+
import { asksOn, createRequests, saysYesOn } from "./lan-requests.mjs";
|
|
36
62
|
import { randomBytes } from "node:crypto";
|
|
37
63
|
import { hostname, networkInterfaces } from "node:os";
|
|
38
64
|
|
|
39
|
-
/** How often a deck asks its peers what they have
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* How long to wait before dialling again while somebody is deciding.
|
|
46
|
-
*
|
|
47
|
-
* A minute is right for the steady state — two decks whose logins all work have
|
|
48
|
-
* nothing to say to each other — and it is far too long for the one moment
|
|
49
|
-
* anybody is watching: the seconds after somebody presses accept on the other
|
|
50
|
-
* machine. Reported as "it should work by itself", from a panel that had been
|
|
51
|
-
* correct for up to fifty-nine more seconds than the person in front of it.
|
|
52
|
-
*
|
|
53
|
-
* So the loop tightens while a request is outstanding and relaxes the moment it
|
|
54
|
-
* is answered — either way. A refusal is an answer, and a deck that said no is
|
|
55
|
-
* not asked every eight seconds.
|
|
56
|
-
*/
|
|
57
|
-
export const ASKING_MS = 8_000;
|
|
65
|
+
/** How often a deck asks its peers what they have, and how soon it asks again
|
|
66
|
+
* while somebody is deciding — see lan-round-timer.mjs, which keeps the
|
|
67
|
+
* clock that picks between them. */
|
|
68
|
+
export { ASKING_MS, SYNC_MS };
|
|
58
69
|
|
|
59
70
|
/** How long to wait before trying the discovery port again while another
|
|
60
71
|
* program holds it. The beacon's own interval: a port that frees up is picked
|
|
@@ -66,24 +77,9 @@ export const BIND_RETRY_MS = 30_000;
|
|
|
66
77
|
* going to answer, and holding the attempt open would stall the next round. */
|
|
67
78
|
const ROUND_MS = 10_000;
|
|
68
79
|
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
* Measured, because the shape of it is not the obvious one: the dial list is
|
|
73
|
-
* keyed `host:port`, not by fingerprint, so five hundred beacons from one
|
|
74
|
-
* address on one port make one row. Five hundred beacons from one address on
|
|
75
|
-
* five hundred PORTS make five hundred rows, and a round dials them one at a
|
|
76
|
-
* time with a ROUND_MS bell on each — so a list that size is eighty minutes of
|
|
77
|
-
* round, and the decks somebody actually paired with sit at the end of it
|
|
78
|
-
* waiting their turn. The ceiling on that without this number is 65,535 rows
|
|
79
|
-
* from a single host.
|
|
80
|
-
*
|
|
81
|
-
* It bounds only what the deck added BY ITSELF. Addresses a person typed are
|
|
82
|
-
* not capped: a list of those is somebody's own decision and the deck is in no
|
|
83
|
-
* position to tell them they have too many machines.
|
|
84
|
-
*/
|
|
85
|
-
export const MAX_AUTO_PEERS = 32;
|
|
86
|
-
|
|
80
|
+
/** The most addresses `autoAsk` may put on the dial list on its own — see
|
|
81
|
+
* lan-dials.mjs, which keeps the list and the cap. */
|
|
82
|
+
export { MAX_AUTO_PEERS };
|
|
87
83
|
|
|
88
84
|
/** What this machine calls itself when the user has not said. The hostname,
|
|
89
85
|
* because that is the word they already use for this machine everywhere else. */
|
|
@@ -121,86 +117,10 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
121
117
|
return out;
|
|
122
118
|
}
|
|
123
119
|
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
* The question behind "can other decks reach this one": a socket accepted from
|
|
128
|
-
* loopback, or from one of this machine's own addresses, is the second deck on
|
|
129
|
-
* this computer talking to the first — which happens on every developer machine
|
|
130
|
-
* and proves nothing at all about the network. Both spellings of loopback are
|
|
131
|
-
* named because both arrive: `127.0.0.1` from a deck that dialled an address
|
|
132
|
-
* and `::1` from one that dialled a name.
|
|
133
|
-
*
|
|
134
|
-
* Pure and separate from the engine so the case that matters — a connection
|
|
135
|
-
* from somewhere else — can be tested without a second machine.
|
|
136
|
-
*/
|
|
137
|
-
export const anotherMachine = (from, mine = []) => {
|
|
138
|
-
const at = String(from ?? "").replace(/^::ffff:/, "").trim();
|
|
139
|
-
return !!at && at !== "127.0.0.1" && at !== "::1" && !mine.includes(at);
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
144
|
-
*
|
|
145
|
-
* It arrived from another machine, so it is read rather than trusted: strings
|
|
146
|
-
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
147
|
-
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
148
|
-
*/
|
|
149
|
-
/** One peer-supplied string as the panel may draw it: no control or format
|
|
150
|
-
* characters, whitespace collapsed, bounded. The rule cleanName applies to a
|
|
151
|
-
* deck's name and lan-about's `field` to a card, applied to the two strings
|
|
152
|
-
* that sit next to the fingerprint in the import dialog. */
|
|
153
|
-
function flatten(v, max) {
|
|
154
|
-
if (typeof v !== "string") return "";
|
|
155
|
-
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
|
|
156
|
-
return [...flat].slice(0, max).join("");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function offered(list) {
|
|
160
|
-
return onePerKey((Array.isArray(list) ? list : [])
|
|
161
|
-
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
162
|
-
// Character-filtered, not merely cut. These two are drawn beside the
|
|
163
|
-
// fingerprint at the moment the operator picks which of a peer's logins to
|
|
164
|
-
// import (LanPeerModal.tsx:497), and a bare slice let a format character
|
|
165
|
-
// through — the same class cleanName strips from the name one frame over.
|
|
166
|
-
.map(a => ({
|
|
167
|
-
key: flatten(a.key, 320),
|
|
168
|
-
email: flatten(a.email, 254),
|
|
169
|
-
alive: storedCopyAlive(a.alive, a.collector),
|
|
170
|
-
// Additive wire field: an older peer omitted it, which means shareable.
|
|
171
|
-
...(a.shareable === false ? { shareable: false } : {}),
|
|
172
|
-
}))
|
|
173
|
-
.filter(a => a.key && a.email))
|
|
174
|
-
// Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
|
|
175
|
-
// it: a peer with duplicate slots could otherwise push a live copy past
|
|
176
|
-
// row fifty and out of the list while offering far fewer than fifty
|
|
177
|
-
// logins. The raw array is already bounded by MAX_FRAME_BYTES.
|
|
178
|
-
.slice(0, 50);
|
|
179
|
-
}
|
|
120
|
+
/** Did this connection come from a different computer — see lan-inbound.mjs,
|
|
121
|
+
* where the record it decides is kept. */
|
|
122
|
+
export { anotherMachine };
|
|
180
123
|
|
|
181
|
-
/**
|
|
182
|
-
* Which account a peer said it is on, as the panel may keep it: the key of one
|
|
183
|
-
* of the accounts it listed in the same frame, that its owner is hiding it, or
|
|
184
|
-
* that it is on one it does not share — and nothing for anything else. A key
|
|
185
|
-
* that is not in its own list is dropped rather than drawn: a deck only ever
|
|
186
|
-
* names an account it shares, and one that names another is saying something
|
|
187
|
-
* this deck will not show.
|
|
188
|
-
*/
|
|
189
|
-
export function heardCurrent(raw, list) {
|
|
190
|
-
if (!raw || typeof raw !== "object") return null;
|
|
191
|
-
if (raw.hidden === true) return { hidden: true };
|
|
192
|
-
if (raw.other === true) return { other: true };
|
|
193
|
-
if (typeof raw.key !== "string") return null;
|
|
194
|
-
return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* One deck's LAN sync, from settings to a healed account.
|
|
199
|
-
*
|
|
200
|
-
* `deps` is every side effect: reading accounts, exporting one, importing one.
|
|
201
|
-
* Injected rather than imported so a test can run a whole round — two engines,
|
|
202
|
-
* two fake stores, one real socket pair — without claude-swap on the machine.
|
|
203
|
-
*/
|
|
204
124
|
/**
|
|
205
125
|
* Whether an account this round just placed is ticked for sharing here (#1188).
|
|
206
126
|
*
|
|
@@ -218,12 +138,99 @@ export function ticksOnArrival(step, via) {
|
|
|
218
138
|
return !!step?.key && step.action === "add" && via !== "tailscale";
|
|
219
139
|
}
|
|
220
140
|
|
|
141
|
+
/** The two permissions that answer for one route — see lan-requests.mjs,
|
|
142
|
+
* where the requests they answer are kept. */
|
|
143
|
+
export { asksOn, saysYesOn };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One question to a deck over a connection connectToPeer opened, and the one
|
|
147
|
+
* frame that answers it: read off the socket, opened through the connection's
|
|
148
|
+
* own reader, and refused as a sentence when the socket closes, fails, runs
|
|
149
|
+
* past the frame cap or goes quiet for ROUND_MS.
|
|
150
|
+
*/
|
|
151
|
+
function askOver(conn, frame) {
|
|
152
|
+
// A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
|
|
153
|
+
//
|
|
154
|
+
// lan-lines.mjs states the rule, on frameReader and MAX_FRAME_BYTES: "a
|
|
155
|
+
// peer that sends a megabyte with no newline in it is not sending a large
|
|
156
|
+
// frame, it is sending nothing at all, expensively... the buffer is
|
|
157
|
+
// ABANDONED rather than grown past it." frameReader enforces it; this
|
|
158
|
+
// reader did not.
|
|
159
|
+
//
|
|
160
|
+
// The frameReader connectToPeer installed IS still attached and does hit
|
|
161
|
+
// MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
|
|
162
|
+
// short-circuits on `settled`, so nothing destroys the socket. This
|
|
163
|
+
// buffer then grew unbounded for the full ROUND_MS at line rate. The
|
|
164
|
+
// reject path also never removed the listener; only roundWith's
|
|
165
|
+
// `finally { conn?.sock?.destroy(); }` stopped it.
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
let buf = "";
|
|
168
|
+
let settled = false;
|
|
169
|
+
const give = (fn, arg) => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
clearTimeout(bell);
|
|
173
|
+
conn.sock.off("data", onData);
|
|
174
|
+
conn.sock.off("close", onClose);
|
|
175
|
+
conn.sock.off("error", onError);
|
|
176
|
+
fn(arg);
|
|
177
|
+
};
|
|
178
|
+
const onClose = () => give(reject, new Error("peer closed the connection"));
|
|
179
|
+
// The errno rides along in the sentence, because faultText reads it
|
|
180
|
+
// there: ECONNRESET is "it hung up" on the row, and a bare "peer
|
|
181
|
+
// connection failed" was the one fault the panel could only repeat.
|
|
182
|
+
const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
|
|
183
|
+
const onData = chunk => {
|
|
184
|
+
buf += chunk;
|
|
185
|
+
if (buf.length > MAX_FRAME_BYTES) {
|
|
186
|
+
conn.sock.destroy();
|
|
187
|
+
return give(reject, new Error("frame too large"));
|
|
188
|
+
}
|
|
189
|
+
const i = buf.indexOf("\n");
|
|
190
|
+
if (i === -1) return;
|
|
191
|
+
let parsed;
|
|
192
|
+
try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
|
|
193
|
+
// THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
|
|
194
|
+
// that is the only way in. A reply that does not open is not a reply
|
|
195
|
+
// with something wrong in it; it is a connection that stopped being
|
|
196
|
+
// the one the handshake proved — altered, replayed, or plain where
|
|
197
|
+
// both ends agreed to seal — so the round ends here instead of
|
|
198
|
+
// reading it as it stands. See frameChannel.
|
|
199
|
+
const got = conn.read(parsed);
|
|
200
|
+
if (conn.sealed && !got) {
|
|
201
|
+
conn.sock.destroy();
|
|
202
|
+
return give(reject, new Error("a reply from that deck did not open"));
|
|
203
|
+
}
|
|
204
|
+
give(resolve, got);
|
|
205
|
+
};
|
|
206
|
+
const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
|
|
207
|
+
bell.unref?.();
|
|
208
|
+
conn.sock.on("data", onData);
|
|
209
|
+
conn.sock.on("close", onClose);
|
|
210
|
+
conn.sock.on("error", onError);
|
|
211
|
+
// And out through its own writer, which seals whenever the reader opens.
|
|
212
|
+
// A dead socket cannot throw here — sendFrame swallows a failed write,
|
|
213
|
+
// and `close`/`error` above are what report it. What CAN throw is the
|
|
214
|
+
// seal running out of counter, which is this deck's limit and not a
|
|
215
|
+
// network drop, so it is rejected under its own name.
|
|
216
|
+
if (conn.sock.destroyed) return onClose();
|
|
217
|
+
try { conn.send(frame); } catch (err) { give(reject, err); }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One deck's LAN sync, from settings to a healed account.
|
|
223
|
+
*
|
|
224
|
+
* `deps` is every side effect: reading accounts, exporting one, importing one.
|
|
225
|
+
* Injected rather than imported so a test can run a whole round — two engines,
|
|
226
|
+
* two fake stores, one real socket pair — without claude-swap on the machine.
|
|
227
|
+
*/
|
|
221
228
|
export function createEngine({
|
|
222
229
|
readAccounts, exportAccount, importAccount, checkArrivals, liveLogin,
|
|
223
230
|
onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
|
|
224
231
|
/**
|
|
225
232
|
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
226
|
-
* lan-
|
|
233
|
+
* lan-beacon exposes it — and for one more that only showed up in use.
|
|
227
234
|
*
|
|
228
235
|
* The suite runs whole engines over real sockets, which is right: a handshake
|
|
229
236
|
* between two of them is the thing being tested and a mock would only check
|
|
@@ -238,13 +245,13 @@ export function createEngine({
|
|
|
238
245
|
* paired deck and to nobody else. See lan-about.mjs. */
|
|
239
246
|
about = null,
|
|
240
247
|
/** Whether this deck seals every frame after the handshake with a deck that
|
|
241
|
-
* says it does too — see frameChannel in lan-
|
|
248
|
+
* says it does too — see frameChannel in lan-wire.mjs. Nothing in the deck
|
|
242
249
|
* turns it off; the suite does, to play a deck from before #810, which is
|
|
243
250
|
* the only way to show that one still heals. */
|
|
244
251
|
sealFrames = true,
|
|
245
252
|
/** Whether this deck mixes a key pair made for each connection into that
|
|
246
253
|
* connection's key, with a deck that says it does too — see sessionKey in
|
|
247
|
-
* lan-
|
|
254
|
+
* lan-wire.mjs. Nothing in the deck turns this off either; the suite does,
|
|
248
255
|
* to play a deck of #810's version, which seals and does not mix. It rides
|
|
249
256
|
* on `sealFrames`: with that off, this deck says neither. */
|
|
250
257
|
ephemeral = true,
|
|
@@ -291,66 +298,20 @@ export function createEngine({
|
|
|
291
298
|
let identity = null;
|
|
292
299
|
let beacon = null;
|
|
293
300
|
let server = null;
|
|
294
|
-
let timer = null;
|
|
295
301
|
/** This engine, for the helpers below `apply` that need to press its own
|
|
296
|
-
* accept
|
|
297
|
-
*
|
|
298
|
-
*
|
|
302
|
+
* accept or add its own peer — the requests (see lan-requests.mjs, which
|
|
303
|
+
* reach it through engineNow), and the callbacks apply hands the listener
|
|
304
|
+
* and the beacon. Set on the first apply, which is the only
|
|
305
|
+
* thing that can start a round or a listener, so nothing reads it before it
|
|
306
|
+
* is there. */
|
|
299
307
|
let engine = null;
|
|
300
|
-
/**
|
|
301
|
-
*
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
* In memory rather than on disk. A request that is a day old is not a request
|
|
308
|
-
* any more, and a list of them that survives restarts is a list nobody reads.
|
|
309
|
-
*/
|
|
310
|
-
const pending = new Map();
|
|
311
|
-
const strangers = new Map();
|
|
312
|
-
/**
|
|
313
|
-
* Decks somebody here said no to.
|
|
314
|
-
*
|
|
315
|
-
* WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
|
|
316
|
-
* that keeps asking — it dials on its own timer, and every dial that finds no
|
|
317
|
-
* pin here becomes a fresh request. So `dismiss` took a row off a list that
|
|
318
|
-
* the next minute put back, and on the other machine the refusal was
|
|
319
|
-
* indistinguishable from a deck that had not been answered yet: both are the
|
|
320
|
-
* same `pending` refusal on the wire, and both drew "waiting for the other
|
|
321
|
-
* deck to accept this one" forever.
|
|
322
|
-
*
|
|
323
|
-
* A name kept here is therefore two answers at once. This deck stops asking
|
|
324
|
-
* its owner, and the deck that asked is TOLD — see refuse("declined") in
|
|
325
|
-
* lan-socket.mjs, which is the only way the far end can ever learn that the
|
|
326
|
-
* answer was no rather than not yet.
|
|
327
|
-
*
|
|
328
|
-
* In memory, like the two lists above, and reversible: `allow` takes a name
|
|
329
|
-
* out and the requests come back. Nothing about it is written down, because a
|
|
330
|
-
* refusal that outlives the process is a decision nobody can find to undo.
|
|
331
|
-
*/
|
|
332
|
-
const declined = new Map();
|
|
333
|
-
/** The invite this deck is offering, or null. One at a time: a deck showing
|
|
334
|
-
* two tokens is a deck whose owner cannot say which one they sent. */
|
|
335
|
-
let invite = null;
|
|
336
|
-
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
337
|
-
* recent only, because "what happened" is a question about now. */
|
|
338
|
-
const lastRound = new Map();
|
|
339
|
-
/** What each paired deck said about itself — version, operating system,
|
|
340
|
-
* architecture — keyed by fingerprint, most recent only. Filled from both
|
|
341
|
-
* directions: the manifest a deck answers with, and the question a deck that
|
|
342
|
-
* calls in asks. See lan-about.mjs. */
|
|
343
|
-
const aboutBy = new Map();
|
|
344
|
-
/** The accounts each paired deck offered in its last manifest, keyed by
|
|
345
|
-
* fingerprint. Kept APART from lastRound on purpose: a round that fails
|
|
346
|
-
* replaces that line, and the list a deck offered a minute ago is still the
|
|
347
|
-
* best answer to "what does it share" while it is unreachable. */
|
|
348
|
-
const offersBy = new Map();
|
|
349
|
-
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
350
|
-
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
351
|
-
* pressed it wants to know it happened, and a reader who did not wants to
|
|
352
|
-
* know the list is not a photograph of an hour ago. */
|
|
353
|
-
let roundAt = null;
|
|
308
|
+
/** The invite this deck is offering, from the press that makes it to the
|
|
309
|
+
* proof that spends it — see lan-invite-offer.mjs. */
|
|
310
|
+
const offer = createInviteOffer({ now, onChange, onError });
|
|
311
|
+
/** What the last round did with each deck, when the whole round last
|
|
312
|
+
* finished, and whether anybody it asked is still deciding — see
|
|
313
|
+
* lan-round-record.mjs. */
|
|
314
|
+
const lastRound = createRoundRecord({ now });
|
|
354
315
|
/**
|
|
355
316
|
* Why this deck is not listening, when it is switched on and is not.
|
|
356
317
|
*
|
|
@@ -364,89 +325,30 @@ export function createEngine({
|
|
|
364
325
|
* reach one peer is that peer's row, not the deck's.
|
|
365
326
|
*/
|
|
366
327
|
let stalled = null;
|
|
367
|
-
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
|
|
379
|
-
* is the evidence, and it is the same kind the beacon gives: a timestamp.
|
|
380
|
-
*/
|
|
381
|
-
const spokeAt = new Map();
|
|
382
|
-
/** And the address it spoke FROM, so a deck that only ever calls in can
|
|
383
|
-
* still be said to come over the tailnet or the local network — it has no
|
|
384
|
-
* address of its own here, and without this its row could not say which. */
|
|
385
|
-
const spokeFrom = new Map();
|
|
386
|
-
/** Addresses this deck added because a paired deck called in from them and
|
|
387
|
-
* nothing here dialled it — see learnCaller. Kept until a round proves the
|
|
388
|
-
* address answers: one that does is an ordinary dialled peer from then on
|
|
389
|
-
* and leaves this set; one that does not is a caller this deck cannot reach
|
|
390
|
-
* back (a strict NAT, a one-way path), and its row is taken away again so it
|
|
391
|
-
* reverts to "calls in" rather than failing every round. */
|
|
392
|
-
const calledBack = new Set();
|
|
393
|
-
/**
|
|
394
|
-
* When a connection from ANOTHER MACHINE last arrived on the sync listener.
|
|
395
|
-
*
|
|
396
|
-
* The one fact that settles "can other decks reach this one", and the only
|
|
397
|
-
* one on the whole question that is measured rather than reasoned about: a
|
|
398
|
-
* firewall's configuration is read through three different tools on three
|
|
399
|
-
* platforms, one of which (`ufw`) refuses to show its rules to a process
|
|
400
|
-
* that is not root. An accepted socket needs none of that — the packets got
|
|
401
|
-
* in, whatever any rule file says.
|
|
402
|
-
*
|
|
403
|
-
* ANOTHER MACHINE, checked here and not in the socket: a connection from
|
|
404
|
-
* loopback or from one of this machine's own addresses is the second deck on
|
|
405
|
-
* this computer, which proves nothing about the network. The socket does not
|
|
406
|
-
* hold that list; this does.
|
|
407
|
-
*/
|
|
408
|
-
let inboundAt = null;
|
|
409
|
-
|
|
410
|
-
/** When this deck's listener came up, or null while it is down.
|
|
411
|
-
*
|
|
412
|
-
* THE OTHER HALF OF `inboundAt`. On its own, "nothing has ever connected in"
|
|
413
|
-
* says nothing: a deck that started four seconds ago has the same null as one
|
|
414
|
-
* that has been listening all afternoon while the network talked around it.
|
|
415
|
-
* What makes the silence evidence is how long it has gone on for, and that is
|
|
416
|
-
* a number only the engine holds. See silentInbound in lan-reach.mjs, which
|
|
417
|
-
* is the one verdict in this feature that works on a platform nothing can be
|
|
418
|
-
* asked about. */
|
|
419
|
-
let listeningSince = null;
|
|
420
|
-
|
|
421
|
-
/** The tailnet read's own timer, running only while the switch is on. */
|
|
422
|
-
let tailTimer = null;
|
|
423
|
-
// An in-flight refresh may finish after discovery is disabled or restarted.
|
|
424
|
-
let tailRefreshGeneration = 0;
|
|
425
|
-
|
|
426
|
-
/** Who held the discovery port the last time it was asked, for as long as
|
|
427
|
-
* this deck cannot hear: the answer does not change between two tries half a
|
|
428
|
-
* minute apart, and on Windows asking costs a PowerShell start. Undefined
|
|
429
|
-
* until asked, null when the machine would not say. */
|
|
430
|
-
let holder;
|
|
431
|
-
|
|
432
|
-
/** What the panel says while this deck cannot hear — see createBeacon's
|
|
433
|
-
* `hearing`. Null whenever it can. */
|
|
434
|
-
const deafLine = () => {
|
|
435
|
-
if (!beacon || beacon.hearing()) return null;
|
|
436
|
-
const err = beacon.deafError?.();
|
|
437
|
-
if (err && err.code !== "EADDRINUSE") {
|
|
438
|
-
return `This deck cannot listen on UDP ${DISCOVERY_PORT} (${err.code ?? err.message}), so it hears no other deck announce itself. Other decks still find it and pair with it.`;
|
|
439
|
-
}
|
|
440
|
-
return `${holder ?? "Another program"} is holding UDP ${DISCOVERY_PORT}, so this deck hears no new decks. Others still find it and pair with it, and it takes the port back as soon as it is free.`;
|
|
441
|
-
};
|
|
328
|
+
/** Who has called this deck — when each paired deck last spoke and from
|
|
329
|
+
* where, when another machine last got through, and since when the
|
|
330
|
+
* listener has been up — see lan-inbound.mjs. */
|
|
331
|
+
const inbound = createInbound({ now, localAddresses });
|
|
332
|
+
/** The addresses this deck dials that the beacon did not hand it, and what
|
|
333
|
+
* answered at each — see lan-dials.mjs, where every row says whether a
|
|
334
|
+
* person named it, which is the whole of roundWith's trust rule. */
|
|
335
|
+
const dials = createDials();
|
|
336
|
+
/** Whether this deck can hear other decks announce, who is holding the
|
|
337
|
+
* discovery port while it cannot, and the sentence the panel says about
|
|
338
|
+
* it — see lan-hearing.mjs. */
|
|
339
|
+
const hearing = createHearing({ beaconNow: () => beacon, portHolder, onChange });
|
|
442
340
|
|
|
443
341
|
/** Whether an address is a tailnet one, and whose. Null is the local network
|
|
444
342
|
* — and always is on a deck with no Tailscale reader. */
|
|
445
343
|
const routeTo = addr => routeOf(tailnet?.snapshot?.() ?? null, addr);
|
|
344
|
+
/** The same answer by the name a row carries: "tailscale" for a tailnet
|
|
345
|
+
* address, "lan" for every other. */
|
|
346
|
+
const viaAt = addr => (routeTo(addr) ? "tailscale" : "lan");
|
|
446
347
|
|
|
447
|
-
/** A pairing somebody explicitly removed. Unlike `declined
|
|
448
|
-
* restart because the old dial row
|
|
449
|
-
*
|
|
348
|
+
/** A pairing somebody explicitly removed. Unlike `declined` in
|
|
349
|
+
* lan-requests.mjs, this survives a restart because the old dial row
|
|
350
|
+
* survives too; forgetting the decision would let that row silently
|
|
351
|
+
* recreate the pairing on the next round. */
|
|
450
352
|
const wasUnpaired = fp => Array.isArray(cfg.unpaired) && cfg.unpaired.includes(fp);
|
|
451
353
|
const markUnpaired = (fp, value) => {
|
|
452
354
|
const before = Array.isArray(cfg.unpaired) ? cfg.unpaired : [];
|
|
@@ -459,82 +361,52 @@ export function createEngine({
|
|
|
459
361
|
return true;
|
|
460
362
|
};
|
|
461
363
|
|
|
462
|
-
/** The
|
|
463
|
-
|
|
464
|
-
|
|
364
|
+
/** The decks waiting on somebody here — asked, heard, or told no — and when
|
|
365
|
+
* a switch answers one for the owner by pressing this engine's accept. See
|
|
366
|
+
* lan-requests.mjs. */
|
|
367
|
+
const requests = createRequests({
|
|
368
|
+
now, settings: () => cfg, routeTo, wasUnpaired, engineNow: () => engine, onChange, localAddresses,
|
|
369
|
+
});
|
|
465
370
|
|
|
466
371
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* TURNING IT ON ANNOUNCES AT ONCE, after one read, so the owner's machines
|
|
472
|
-
* hear about this one in the second after the press rather than on the next
|
|
473
|
-
* beacon, up to half a minute later.
|
|
372
|
+
* Pin a deck somebody here chose — pressed accept on, handed an invite to,
|
|
373
|
+
* or joined on an invite of its own — and take back any earlier unpair of
|
|
374
|
+
* it, because choosing it again is the undo. The list, and whether the pin
|
|
375
|
+
* is new.
|
|
474
376
|
*/
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
void tailnet.refresh().then(() => {
|
|
481
|
-
if (startedIn === tailRefreshGeneration && beacon === currentBeacon && cfg.enabled && cfg.tailscale) {
|
|
482
|
-
currentBeacon.announce();
|
|
483
|
-
}
|
|
484
|
-
}, () => {});
|
|
485
|
-
tailTimer = setInterval(() => { void tailnet.refresh(); }, TAILNET_MS);
|
|
486
|
-
tailTimer.unref?.();
|
|
487
|
-
} else if (!want && tailTimer) {
|
|
488
|
-
tailRefreshGeneration++;
|
|
489
|
-
clearInterval(tailTimer);
|
|
490
|
-
tailTimer = null;
|
|
491
|
-
}
|
|
377
|
+
const pin = ({ fp, pub, name }) => {
|
|
378
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub, name, at: now() });
|
|
379
|
+
cfg = { ...cfg, trusted: list };
|
|
380
|
+
markUnpaired(fp, false);
|
|
381
|
+
return { list, added };
|
|
492
382
|
};
|
|
493
383
|
|
|
494
|
-
/**
|
|
495
|
-
*
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
key: accountKey(a.email, a.orgUuid),
|
|
500
|
-
email: a.email,
|
|
501
|
-
org: a.orgUuid,
|
|
502
|
-
alive: storedCopyAlive(a.alive, a.collector),
|
|
503
|
-
// False only for a login the wiring knows this process cannot read (a
|
|
504
|
-
// Mac whose Keychain will not open from here). Absent means readable,
|
|
505
|
-
// which is every deck that does not say.
|
|
506
|
-
readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
|
|
507
|
-
unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
|
|
508
|
-
? SENDER_UNREADABLE : "export failed",
|
|
509
|
-
num: a.num,
|
|
510
|
-
// The one this deck is on — claude-swap's own answer, one at most.
|
|
511
|
-
active: a.active === true,
|
|
512
|
-
}));
|
|
513
|
-
};
|
|
384
|
+
/** Reading the tailnet on a timer while the switch is on, announcing at
|
|
385
|
+
* once when it is turned on — see lan-tailnet-poll.mjs. */
|
|
386
|
+
const tailPoll = createTailnetPoll({
|
|
387
|
+
tailnet, beaconNow: () => beacon, wanted: () => cfg.enabled && cfg.tailscale,
|
|
388
|
+
});
|
|
514
389
|
|
|
515
|
-
/** This deck's
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
390
|
+
/** This deck's accounts in the shape the rules want — see lan-accounts.mjs.
|
|
391
|
+
* Read through the same function the panel uses, so a row can never be alive
|
|
392
|
+
* here and dead there. */
|
|
393
|
+
const localAccounts = async () => syncAccounts(await readAccounts());
|
|
394
|
+
|
|
395
|
+
/** This deck's side of the manifest exchange: the frame it sends, what it
|
|
396
|
+
* keeps of the one it hears — a paired deck's card and the logins it offers,
|
|
397
|
+
* kept apart from lastRound — and what a row reads of that — see
|
|
398
|
+
* lan-manifest.mjs. */
|
|
399
|
+
const { manifestFrame, keepManifest, heardOf } = createManifests({
|
|
400
|
+
about, now, myFp: () => identity.fp, settings: () => cfg,
|
|
401
|
+
});
|
|
523
402
|
|
|
524
|
-
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
525
|
-
* accepted. Nothing reaches this before both, which is the whole point of
|
|
526
|
-
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
527
|
-
* connection's — see sessionKey. `ctx.send` seals whatever it is handed when
|
|
528
|
-
* both ends said they seal, so nothing below has to know which kind of deck
|
|
529
|
-
* asked — see frameChannel. */
|
|
530
403
|
/** Does this deck already hold an address it dials for `fp`? A beacon row it
|
|
531
404
|
* still hears, or a typed/learned row that answered as that deck. When
|
|
532
405
|
* neither is true, the only way it ever reaches that deck is if the deck
|
|
533
406
|
* keeps calling — and a called deck is never pulled from. */
|
|
534
407
|
const dialsAlready = fp => {
|
|
535
408
|
if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
|
|
536
|
-
|
|
537
|
-
return false;
|
|
409
|
+
return dials.answersAs(fp);
|
|
538
410
|
};
|
|
539
411
|
|
|
540
412
|
/**
|
|
@@ -550,8 +422,9 @@ export function createEngine({
|
|
|
550
422
|
* in its hello which port it LISTENS on, and that pair is dialable. Adding it
|
|
551
423
|
* makes the next round reach the caller and pull — the same dial-back that
|
|
552
424
|
* accepting a deck and joining by invite already do, extended to a peer that
|
|
553
|
-
* simply calls. In memory,
|
|
554
|
-
* next call re-adds it, and nothing here writes
|
|
425
|
+
* simply calls. In memory, unlike those two: a settings write clears it and
|
|
426
|
+
* the next call re-adds it, and nothing here writes a caller's address to
|
|
427
|
+
* disk.
|
|
555
428
|
*/
|
|
556
429
|
const learnCaller = ctx => {
|
|
557
430
|
const fp = ctx?.peerFp;
|
|
@@ -561,162 +434,75 @@ export function createEngine({
|
|
|
561
434
|
if (!trustedPeer(cfg.trusted, fp)) return;
|
|
562
435
|
if (dialsAlready(fp)) return;
|
|
563
436
|
// As a row the deck ADDED ITSELF, not one a person typed: capped like every
|
|
564
|
-
// other automatic row, and — through
|
|
437
|
+
// other automatic row, and — through its trial — taken away again if the
|
|
565
438
|
// address turns out not to answer. The caller is already trusted, so the
|
|
566
439
|
// round dials and pulls without a press; `typed` decides only the cap and
|
|
567
440
|
// the undo, never the trust. See roundWith.
|
|
568
441
|
if (engine.addPeer(at, port, { typed: false })) {
|
|
569
|
-
|
|
570
|
-
calledBack.add(`${at}:${port}`);
|
|
442
|
+
dials.trial(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
|
|
571
443
|
onChange?.();
|
|
572
444
|
}
|
|
573
445
|
};
|
|
574
446
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (ctx?.peerFp) {
|
|
583
|
-
spokeAt.set(ctx.peerFp, now());
|
|
584
|
-
const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
585
|
-
if (from) spokeFrom.set(ctx.peerFp, from);
|
|
586
|
-
learnCaller(ctx);
|
|
587
|
-
}
|
|
588
|
-
try {
|
|
589
|
-
if (msg.t === "manifest") {
|
|
590
|
-
// THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
|
|
591
|
-
// that calls in ever says what it is: nothing here dials it, so nothing
|
|
592
|
-
// here ever asks. A seal that does not open is a deck that said nothing.
|
|
593
|
-
const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
|
|
594
|
-
if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
|
|
595
|
-
// AND SO DOES ITS LIST, from a deck new enough to send one: what it
|
|
596
|
-
// offers, and which of those it is on. Kept only when it came — an
|
|
597
|
-
// older caller asks with its card alone, and a missing list is not an
|
|
598
|
-
// empty one. This is the only way a deck nothing here dials is ever
|
|
599
|
-
// known by what it offers.
|
|
600
|
-
if (Array.isArray(msg.accounts)) {
|
|
601
|
-
const list = offered(msg.accounts);
|
|
602
|
-
offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
|
|
603
|
-
}
|
|
604
|
-
const accounts = await localAccounts();
|
|
605
|
-
// Reading the store can take long enough for the owner to unpair this
|
|
606
|
-
// deck. Do not disclose account identities or the active account from
|
|
607
|
-
// a manifest assembled before that decision.
|
|
608
|
-
if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
|
|
609
|
-
return ctx.send({
|
|
610
|
-
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
611
|
-
...currentFor(accounts, cfg.shared, cfg.shareActive),
|
|
612
|
-
...cardFor(ctx.key, ctx.peerFp),
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
if (msg.t === "want") {
|
|
616
|
-
// A listener may have authenticated this socket before its owner
|
|
617
|
-
// unpaired the caller or switched sharing off. Recheck at the moment
|
|
618
|
-
// a credential is requested and again after every asynchronous read.
|
|
619
|
-
const maySend = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx.peerFp)
|
|
620
|
-
&& cfg.shared.includes(msg.key);
|
|
621
|
-
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
622
|
-
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
623
|
-
// session says who connected; this says they are asking for this
|
|
624
|
-
// account, now. A long-lived connection authenticated an hour ago is
|
|
625
|
-
// not a statement about now.
|
|
626
|
-
const want = transferChallenge(ctx.key, {
|
|
627
|
-
nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
|
|
628
|
-
});
|
|
629
|
-
if (typeof msg.proof !== "string" || msg.proof !== want) {
|
|
630
|
-
return ctx.send({ t: "no", why: "proof" });
|
|
631
|
-
}
|
|
632
|
-
// Only what the user ticked, checked again here rather than trusted
|
|
633
|
-
// from the manifest we sent: the list can change between the two, and
|
|
634
|
-
// the answer that matters is the one at the moment of sending.
|
|
635
|
-
const accounts = await localAccounts();
|
|
636
|
-
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
637
|
-
// The slot manifestFor told the peer about, by the same rule: with two
|
|
638
|
-
// slots for one identity, an expired one listed first must not hide a
|
|
639
|
-
// live one behind it.
|
|
640
|
-
const mine = slotFor(accounts, msg.key);
|
|
641
|
-
if (!mine) return ctx.send({ t: "no", why: "not mine to give" });
|
|
642
|
-
// A LOGIN THIS DECK CANNOT READ IS SAID SO, from state rather than from
|
|
643
|
-
// a failed export: no subprocess, and nothing the CLI printed. The
|
|
644
|
-
// asking deck prints it under the account, so the person learns which
|
|
645
|
-
// machine to unlock instead of reading "export failed".
|
|
646
|
-
if (!mine.readable) return ctx.send({ t: "no", why: mine.unreadableWhy });
|
|
647
|
-
if (!mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
|
|
648
|
-
// The active slot exports the live CLI login, and its verdict can
|
|
649
|
-
// predate a `/login` as somebody else, so ask the CLI who it is now.
|
|
650
|
-
if (mine.active && liveLogin && !liveLoginIs(await liveLogin(), mine.email, mine.org)) {
|
|
651
|
-
return ctx.send({ t: "no", why: "export failed" });
|
|
652
|
-
}
|
|
653
|
-
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
654
|
-
const blob = await exportAccount(mine.num, msg.key);
|
|
655
|
-
if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
|
|
656
|
-
// A Mac's failed export refreshes the verdict behind `readable` in the
|
|
657
|
-
// background, so when the Keychain was why, the next ask is answered by
|
|
658
|
-
// the line above.
|
|
659
|
-
if (!blob) return ctx.send({ t: "no", why: "export failed" });
|
|
660
|
-
const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
|
|
661
|
-
return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
|
|
662
|
-
}
|
|
663
|
-
} catch (err) {
|
|
664
|
-
onError?.("serve", err);
|
|
665
|
-
ctx.send({ t: "no", why: "error" });
|
|
666
|
-
}
|
|
667
|
-
};
|
|
447
|
+
/** What this deck answers a paired deck that asks — its list, or one login
|
|
448
|
+
* sealed for it — and the checks that decide whether it answers at all.
|
|
449
|
+
* See lan-serve.mjs. */
|
|
450
|
+
const { serve } = createServe({
|
|
451
|
+
settings: () => cfg, serverNow: () => server, myFp: () => identity.fp,
|
|
452
|
+
inbound, learnCaller, keepManifest, manifestFrame, localAccounts, liveLogin, exportAccount, onError,
|
|
453
|
+
});
|
|
668
454
|
|
|
669
455
|
/**
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* way. A deck that DIALLED this one finishes a handshake at the listener and
|
|
674
|
-
* arrives through `onPending`; a deck this one dialled finishes the same
|
|
675
|
-
* handshake in roundWith. Both have proved they hold the key they announced,
|
|
676
|
-
* and neither has been agreed to. Before #969 only the first raised a
|
|
677
|
-
* request and the second silently pinned itself.
|
|
456
|
+
* Dial a deck just paired with from now on, and keep the address: in the
|
|
457
|
+
* dial list, and through onDial in prefs, so a restart does not make the
|
|
458
|
+
* pairing one-way again.
|
|
678
459
|
*
|
|
679
|
-
* SAY
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* A deck already told no does not become a row again. On the inbound path
|
|
686
|
-
* lan-socket refuses it before this is ever called; on the outbound one there
|
|
687
|
-
* is nothing before this, so the check lives here.
|
|
460
|
+
* AND SAY WHO IS THERE, NOW. What the dial list learned at an address is
|
|
461
|
+
* what joins a dialled row to a heard one (see `learned` in lan-dials.mjs),
|
|
462
|
+
* and it was only ever filled by a round that succeeded — so
|
|
463
|
+
* between accepting a deck and the next round, one machine appeared as two
|
|
464
|
+
* rows. We already know the answer here: the handshake that just finished
|
|
465
|
+
* said so.
|
|
688
466
|
*/
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
// tailnet is answered by the Tailscale pair, and only for a machine on this
|
|
694
|
-
// person's own Tailscale account; anything else on the tailnet waits for a
|
|
695
|
-
// press whatever either switch says. See routeOf.
|
|
696
|
-
const route = routeTo(entry.addr);
|
|
697
|
-
const via = route ? "tailscale" : "lan";
|
|
698
|
-
const own = !!route?.own;
|
|
699
|
-
pending.set(entry.fp, { ...entry, via, own, at: had?.at ?? now(), lastAt: now() });
|
|
700
|
-
if (!wasUnpaired(entry.fp) && saysYesOn(via) && (via === "lan" || own)) {
|
|
701
|
-
engine?.accept(entry.fp, { byHand: false });
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
if (!had) onChange?.();
|
|
467
|
+
const keepDialling = (addr, port, met) => {
|
|
468
|
+
engine.addPeer(addr, port);
|
|
469
|
+
onDial?.(`${addr}:${port}`);
|
|
470
|
+
dials.meet(`${addr}:${port}`, met);
|
|
705
471
|
};
|
|
706
472
|
|
|
707
|
-
/**
|
|
708
|
-
*
|
|
473
|
+
/** Somebody used the token. It is retired — one that pairs twice is one
|
|
474
|
+
* worth stealing twice — and they are pinned.
|
|
709
475
|
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
476
|
+
* RETIRED FIRST, so nothing that goes wrong in the pairing after it can
|
|
477
|
+
* leave a spent token live (#1137). The listener calls this for every
|
|
478
|
+
* proof that holds, a deck it already had included; pinning one of those
|
|
479
|
+
* again changes nothing but its name, and dialling it back is what joining
|
|
480
|
+
* does on the other end too. */
|
|
481
|
+
const inviteUsed = entry => {
|
|
482
|
+
offer.retire();
|
|
483
|
+
const { list } = pin(entry);
|
|
484
|
+
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
485
|
+
// deck with no way to reach it: an inbound connection puts nothing in
|
|
486
|
+
// the dial list. Without this the pairing is mutual in the trusted
|
|
487
|
+
// list and one-way in fact — and `addPeer` alone lives in memory, so
|
|
488
|
+
// it would be one-way again after the next restart.
|
|
489
|
+
if (entry.addr && entry.port) keepDialling(entry.addr, entry.port, { fp: entry.fp, name: entry.name || "" });
|
|
490
|
+
onTrust?.(list);
|
|
491
|
+
onChange?.();
|
|
492
|
+
};
|
|
716
493
|
|
|
494
|
+
/** Another deck is using this one's key. Take a new key and keep it. Two
|
|
495
|
+
* decks with one identity are invisible to each other forever otherwise,
|
|
496
|
+
* and the second one to notice moving is enough — whichever notices
|
|
497
|
+
* first, moves. */
|
|
498
|
+
const idClash = () => {
|
|
499
|
+
const fresh = identityFrom("");
|
|
500
|
+
onIdentity?.(fresh.secret);
|
|
501
|
+
onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
|
|
502
|
+
};
|
|
717
503
|
/** How this deck reached that peer. A beacon row says so; a typed address is
|
|
718
|
-
* read from the routing table
|
|
719
|
-
const viaOf = peer => peer.via ?? (
|
|
504
|
+
* read from the routing table. The round and the peer list both ask this. */
|
|
505
|
+
const viaOf = peer => peer.via ?? viaAt(peer.addr);
|
|
720
506
|
|
|
721
507
|
/** Checks the logins a round brought, once, and records any reason one is
|
|
722
508
|
* unusable here on its row. An unanswered check is not a failure. */
|
|
@@ -751,83 +537,18 @@ export function createEngine({
|
|
|
751
537
|
ask: cfg.pairingMode !== "invite",
|
|
752
538
|
sealFrames, ephemeral,
|
|
753
539
|
});
|
|
754
|
-
|
|
755
|
-
//
|
|
756
|
-
// lan-socket.mjs's header states the rule: "a peer that sends a megabyte
|
|
757
|
-
// with no newline in it is not sending a large frame, it is sending
|
|
758
|
-
// nothing at all, expensively... the buffer is ABANDONED rather than
|
|
759
|
-
// grown past it." frameReader enforces it; this reader did not.
|
|
760
|
-
//
|
|
761
|
-
// The frameReader connectToPeer installed IS still attached and does hit
|
|
762
|
-
// MAX_FRAME_BYTES — but it only sets its own flag and calls `fail`, which
|
|
763
|
-
// short-circuits on `settled`, so nothing destroys the socket. This
|
|
764
|
-
// buffer then grew unbounded for the full ROUND_MS at line rate. The
|
|
765
|
-
// reject path also never removed the listener; only roundWith's
|
|
766
|
-
// `finally { conn?.sock?.destroy(); }` stopped it.
|
|
767
|
-
const ask = frame => new Promise((resolve, reject) => {
|
|
768
|
-
let buf = "";
|
|
769
|
-
let settled = false;
|
|
770
|
-
const give = (fn, arg) => {
|
|
771
|
-
if (settled) return;
|
|
772
|
-
settled = true;
|
|
773
|
-
clearTimeout(bell);
|
|
774
|
-
conn.sock.off("data", onData);
|
|
775
|
-
conn.sock.off("close", onClose);
|
|
776
|
-
conn.sock.off("error", onError);
|
|
777
|
-
fn(arg);
|
|
778
|
-
};
|
|
779
|
-
const onClose = () => give(reject, new Error("peer closed the connection"));
|
|
780
|
-
// The errno rides along in the sentence, because faultText reads it
|
|
781
|
-
// there: ECONNRESET is "it hung up" on the row, and a bare "peer
|
|
782
|
-
// connection failed" was the one fault the panel could only repeat.
|
|
783
|
-
const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
|
|
784
|
-
const onData = chunk => {
|
|
785
|
-
buf += chunk;
|
|
786
|
-
if (buf.length > MAX_FRAME_BYTES) {
|
|
787
|
-
conn.sock.destroy();
|
|
788
|
-
return give(reject, new Error("frame too large"));
|
|
789
|
-
}
|
|
790
|
-
const i = buf.indexOf("\n");
|
|
791
|
-
if (i === -1) return;
|
|
792
|
-
let parsed;
|
|
793
|
-
try { parsed = JSON.parse(buf.slice(0, i)); } catch { return give(reject, new Error("bad reply")); }
|
|
794
|
-
// THROUGH THE CONNECTION'S OWN READER, and on a sealed connection
|
|
795
|
-
// that is the only way in. A reply that does not open is not a reply
|
|
796
|
-
// with something wrong in it; it is a connection that stopped being
|
|
797
|
-
// the one the handshake proved — altered, replayed, or plain where
|
|
798
|
-
// both ends agreed to seal — so the round ends here instead of
|
|
799
|
-
// reading it as it stands. See frameChannel.
|
|
800
|
-
const got = conn.read(parsed);
|
|
801
|
-
if (conn.sealed && !got) {
|
|
802
|
-
conn.sock.destroy();
|
|
803
|
-
return give(reject, new Error("a reply from that deck did not open"));
|
|
804
|
-
}
|
|
805
|
-
give(resolve, got);
|
|
806
|
-
};
|
|
807
|
-
const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
|
|
808
|
-
bell.unref?.();
|
|
809
|
-
conn.sock.on("data", onData);
|
|
810
|
-
conn.sock.on("close", onClose);
|
|
811
|
-
conn.sock.on("error", onError);
|
|
812
|
-
// And out through its own writer, which seals whenever the reader opens.
|
|
813
|
-
// A dead socket cannot throw here — sendFrame swallows a failed write,
|
|
814
|
-
// and `close`/`error` above are what report it. What CAN throw is the
|
|
815
|
-
// seal running out of counter, which is this deck's limit and not a
|
|
816
|
-
// network drop, so it is rejected under its own name.
|
|
817
|
-
if (conn.sock.destroyed) return onClose();
|
|
818
|
-
try { conn.send(frame); } catch (err) { give(reject, err); }
|
|
819
|
-
});
|
|
540
|
+
const ask = frame => askOver(conn, frame);
|
|
820
541
|
|
|
821
542
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
822
543
|
// and nothing else until somebody answers it — and once one has, the deck
|
|
823
544
|
// on the other end has told us what it calls itself. The row says that
|
|
824
545
|
// from then on, because "Constantin-PC" is what the person who typed the
|
|
825
546
|
// address was trying to reach.
|
|
826
|
-
|
|
547
|
+
//
|
|
827
548
|
// A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
|
|
828
549
|
// until it proved the deck can reach it; from here it is dialled like any
|
|
829
550
|
// other and is no longer a candidate for the undo below. See learnCaller.
|
|
830
|
-
|
|
551
|
+
dials.answered(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
831
552
|
|
|
832
553
|
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
|
|
833
554
|
// deck we have no pin for used to mean the person at this keyboard put
|
|
@@ -844,7 +565,7 @@ export function createEngine({
|
|
|
844
565
|
// dial list. The next round reached it, arrived here with no pin, and
|
|
845
566
|
// read "no pin" as "somebody typed this". Nobody typed anything. One
|
|
846
567
|
// unsolicited packet, zero presses, and the far end was in `cfg.trusted`
|
|
847
|
-
// — which
|
|
568
|
+
// — which lan-deck.mjs writes to prefs.json, and which is the whole inbound
|
|
848
569
|
// gate — so from then on it could authenticate to `serve` and ask for
|
|
849
570
|
// every account the owner had ticked. Reproduced end to end before this
|
|
850
571
|
// line changed.
|
|
@@ -866,7 +587,7 @@ export function createEngine({
|
|
|
866
587
|
// handshake it just finished is the same evidence either way — a real
|
|
867
588
|
// deck holding the key it announced. What is missing is the press,
|
|
868
589
|
// and that is what this asks for.
|
|
869
|
-
askToAccept({
|
|
590
|
+
requests.askToAccept({
|
|
870
591
|
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
871
592
|
addr: peer.addr, port: peer.port,
|
|
872
593
|
});
|
|
@@ -891,19 +612,12 @@ export function createEngine({
|
|
|
891
612
|
// The owner can revoke trust or disable sync while the store is read.
|
|
892
613
|
// Never send this deck's account identities on that old connection.
|
|
893
614
|
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
894
|
-
const theirs = await ask(
|
|
895
|
-
t: "manifest", accounts: manifestFor(mine, cfg.shared),
|
|
896
|
-
...currentFor(mine, cfg.shared, cfg.shareActive),
|
|
897
|
-
...cardFor(conn.key, conn.peerFp),
|
|
898
|
-
});
|
|
615
|
+
const theirs = await ask(manifestFrame(mine, conn.key, conn.peerFp));
|
|
899
616
|
// A response from a round that was stopped or unpaired is stale even
|
|
900
617
|
// when the peer had already sent it before the setting changed.
|
|
901
618
|
if (!stillPaired()) throw new Error("peer no longer paired");
|
|
902
619
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
903
|
-
const
|
|
904
|
-
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
905
|
-
const list = offered(theirs.accounts);
|
|
906
|
-
offersBy.set(conn.peerFp, { at: now(), accounts: list, current: heardCurrent(theirs.current, list) });
|
|
620
|
+
const list = keepManifest(conn.key, theirs, conn.peerFp);
|
|
907
621
|
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
908
622
|
// a peer cannot push an account at me that I never agreed to hold.
|
|
909
623
|
// A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
|
|
@@ -913,7 +627,8 @@ export function createEngine({
|
|
|
913
627
|
// them, which is the whole of "I do not want to paste blobs any more".
|
|
914
628
|
// What can reach this is what a deck somebody here pressed accept on
|
|
915
629
|
// chose to offer.
|
|
916
|
-
// OVER `list`, NOT THE RAW ARRAY. `offered
|
|
630
|
+
// OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
|
|
631
|
+
// list through above (see lan-manifest.mjs), slices to 50 and type-
|
|
917
632
|
// filters `key` and `email`; this line read `theirs.accounts` and got
|
|
918
633
|
// neither. syncAction answers "add" for anything this deck lacks and an
|
|
919
634
|
// add needs no tick, so a peer answering `manifest` with thousands of
|
|
@@ -929,10 +644,12 @@ export function createEngine({
|
|
|
929
644
|
// round. A heal unticked mid-round ends only that heal: the adds behind
|
|
930
645
|
// it need no tick, and the skipped row says why rather than vanishing.
|
|
931
646
|
const stillWanted = step => step.action !== "heal" || cfg.shared.includes(step.key);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
647
|
+
/** One login, asked for and opened: a `want` carrying its own proof that
|
|
648
|
+
* names the account (see transferChallenge), and the `have` opened under
|
|
649
|
+
* the additional data it was sealed with. The login, or why there is
|
|
650
|
+
* none — the peer's refusal as this deck records it, or a seal that did
|
|
651
|
+
* not open. */
|
|
652
|
+
const wantLogin = async step => {
|
|
936
653
|
const nonce = randomBytes(12).toString("hex");
|
|
937
654
|
const reply = await ask({
|
|
938
655
|
t: "want", key: step.key, nonce,
|
|
@@ -940,9 +657,16 @@ export function createEngine({
|
|
|
940
657
|
nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
|
|
941
658
|
}),
|
|
942
659
|
});
|
|
943
|
-
if (reply?.t !== "have" || !reply.sealed)
|
|
944
|
-
const blob = open(conn.key, reply.sealed,
|
|
945
|
-
|
|
660
|
+
if (reply?.t !== "have" || !reply.sealed) return { why: peerWhy(reply?.why) };
|
|
661
|
+
const blob = open(conn.key, reply.sealed, credentialAad(conn.peerFp, identity.fp, step.key));
|
|
662
|
+
return blob ? { blob } : { why: "could not open" };
|
|
663
|
+
};
|
|
664
|
+
let cut = false;
|
|
665
|
+
for (const step of wanted) {
|
|
666
|
+
if (!stillPaired()) { cut = true; break; }
|
|
667
|
+
if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
|
|
668
|
+
const { blob, why } = await wantLogin(step);
|
|
669
|
+
if (!blob) { done.push({ ...step, ok: false, why }); continue; }
|
|
946
670
|
// Unpairing, disabling LAN, or unticking a heal while export was in
|
|
947
671
|
// progress takes effect before the received credential touches disk.
|
|
948
672
|
if (!stillPaired()) { cut = true; break; }
|
|
@@ -1002,7 +726,7 @@ export function createEngine({
|
|
|
1002
726
|
// can outlast the peer's thirty-second idle timer, and one ask covers
|
|
1003
727
|
// every login the round brought.
|
|
1004
728
|
await markArrivals(done);
|
|
1005
|
-
lastRound.
|
|
729
|
+
lastRound.keep(peer.fp, { at: now(), name: peer.name, offered: list.length, done });
|
|
1006
730
|
if (done.length) onChange?.();
|
|
1007
731
|
return done;
|
|
1008
732
|
} catch (err) {
|
|
@@ -1014,20 +738,17 @@ export function createEngine({
|
|
|
1014
738
|
// bring are checked the same way a finished round's are.
|
|
1015
739
|
await markArrivals(done);
|
|
1016
740
|
if (session !== startedIn) return [];
|
|
1017
|
-
lastRound.
|
|
741
|
+
lastRound.keep(peer.fp, { at: now(), name: peer.name, error: err.message, done });
|
|
1018
742
|
if (done.length) onChange?.();
|
|
1019
743
|
// A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
|
|
1020
744
|
// from a paired deck's inbound call, and this round was the test of
|
|
1021
745
|
// whether the call can be returned. It could not — a strict NAT, a
|
|
1022
746
|
// one-way path — so the row is removed rather than left to fail every
|
|
1023
747
|
// minute, and the peer goes back to "calls in". Its next call tries once
|
|
1024
|
-
// more. A row that answered has already left
|
|
748
|
+
// more. A row that answered has already left its trial above.
|
|
1025
749
|
const at = `${peer.addr}:${peer.port}`;
|
|
1026
|
-
if (
|
|
1027
|
-
|
|
1028
|
-
manual.delete(at);
|
|
1029
|
-
learned.delete(at);
|
|
1030
|
-
lastRound.delete(peer.fp);
|
|
750
|
+
if (dials.failed(at)) {
|
|
751
|
+
lastRound.drop(peer.fp);
|
|
1031
752
|
onChange?.();
|
|
1032
753
|
}
|
|
1033
754
|
return done;
|
|
@@ -1036,31 +757,6 @@ export function createEngine({
|
|
|
1036
757
|
}
|
|
1037
758
|
};
|
|
1038
759
|
|
|
1039
|
-
/** Peers the user typed in, which the beacon will never find.
|
|
1040
|
-
*
|
|
1041
|
-
* Broadcast dies at the first router and is dropped by a switch that
|
|
1042
|
-
* filters it, so a deck across a VPN or on another subnet is unreachable by
|
|
1043
|
-
* discovery and perfectly reachable by address. Typing one is a decision to
|
|
1044
|
-
* trust whatever answers there the first time, and to pin it: an address is
|
|
1045
|
-
* a way to reach a deck, and the accept on the other machine is what lets
|
|
1046
|
-
* anything move.
|
|
1047
|
-
*
|
|
1048
|
-
* Keyed by `host:port` rather than by fingerprint, because a fingerprint is
|
|
1049
|
-
* what a deck says about itself after the handshake and this list has to
|
|
1050
|
-
* exist before there has been one.
|
|
1051
|
-
*
|
|
1052
|
-
* EVERY ROW SAYS WHERE IT CAME FROM, in `typed`, and the difference decides
|
|
1053
|
-
* whether reaching it may pin a key sight unseen. A row somebody put in the
|
|
1054
|
-
* address field — or pressed accept on, or joined by invite — is a person
|
|
1055
|
-
* naming a machine. A row `autoAsk` added from a beacon is this deck
|
|
1056
|
-
* answering a shout, which is not the same claim and must not read as one.
|
|
1057
|
-
* See roundWith, where the difference is the whole of the trust rule. */
|
|
1058
|
-
const manual = new Map();
|
|
1059
|
-
/** What answered at a typed address, once something has. Keyed the same way
|
|
1060
|
-
* `manual` is, because until a connection succeeds an address is all there
|
|
1061
|
-
* is to key on. */
|
|
1062
|
-
const learned = new Map();
|
|
1063
|
-
|
|
1064
760
|
const oneRound = async () => {
|
|
1065
761
|
if (!beacon) return [];
|
|
1066
762
|
const startedIn = session;
|
|
@@ -1081,12 +777,12 @@ export function createEngine({
|
|
|
1081
777
|
// AND ONE DIAL PER DECK, not one per address. A row asked from a tailnet
|
|
1082
778
|
// beacon keeps that address after the same deck is heard on the local
|
|
1083
779
|
// network, and both used to be dialled every round — two handshakes, and
|
|
1084
|
-
// two lines of work for one machine. `
|
|
780
|
+
// two lines of work for one machine. `dials.metAt` says which deck a row
|
|
1085
781
|
// reached; the heard row already dials it by the better route.
|
|
1086
782
|
const heardFps = new Set(heard.map(p => p.fp));
|
|
1087
|
-
const typed =
|
|
783
|
+
const typed = dials.rows().filter(p => {
|
|
1088
784
|
const at = `${p.addr}:${p.port}`;
|
|
1089
|
-
if (seen.has(at) || heardFps.has(
|
|
785
|
+
if (seen.has(at) || heardFps.has(dials.metAt(at)?.fp)) return false;
|
|
1090
786
|
return cfg.tailscale || p.typed || !routeTo(p.addr);
|
|
1091
787
|
});
|
|
1092
788
|
for (const peer of [...heard, ...typed]) {
|
|
@@ -1101,118 +797,130 @@ export function createEngine({
|
|
|
1101
797
|
all.push(...await roundWith(peer));
|
|
1102
798
|
}
|
|
1103
799
|
if (session !== startedIn) return all;
|
|
1104
|
-
|
|
800
|
+
lastRound.finished();
|
|
1105
801
|
return all;
|
|
1106
802
|
};
|
|
1107
803
|
|
|
1108
|
-
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
*
|
|
1115
|
-
|
|
1116
|
-
* round was asked to ask all of them — so it waits. A check asked for during
|
|
1117
|
-
* a round can often join it, and sometimes cannot. Both need somewhere to
|
|
1118
|
-
* stand that is after whatever is in flight, and this is it.
|
|
1119
|
-
*
|
|
1120
|
-
* The tail never rejects: `roundWith` reports a failure per peer instead of
|
|
1121
|
-
* throwing, and anything else is swallowed here rather than left to stop
|
|
1122
|
-
* every turn after it. The caller of the turn that threw still hears it.
|
|
1123
|
-
*/
|
|
1124
|
-
let _turn = Promise.resolve();
|
|
1125
|
-
const inTurn = job => {
|
|
1126
|
-
const run = _turn.then(job);
|
|
1127
|
-
_turn = run.then(() => {}, () => {});
|
|
1128
|
-
return run;
|
|
1129
|
-
};
|
|
804
|
+
/** The line every round waits in, whole or one deck — see lan-turns.mjs,
|
|
805
|
+
* which is why a round asked for while another runs joins it (#1040) and a
|
|
806
|
+
* check waits behind it (#1132). */
|
|
807
|
+
const turns = createTurns();
|
|
808
|
+
const round = () => turns.round(session, oneRound);
|
|
809
|
+
/** When the next round runs: a minute at rest, seconds while a deck this one
|
|
810
|
+
* dialled is deciding — see lan-round-timer.mjs. */
|
|
811
|
+
const roundTimer = createRoundTimer({ round, waiting: lastRound.waitingOnSomebody });
|
|
1130
812
|
|
|
1131
|
-
/**
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
813
|
+
/** What status() says about Tailscale, for a deck that has a reader: whether
|
|
814
|
+
* the machine has it at all, and what it can see. */
|
|
815
|
+
const tailnetStatus = () => {
|
|
816
|
+
const t = tailnet.snapshot?.() ?? null;
|
|
817
|
+
return {
|
|
818
|
+
found: !!tailnet.found?.(),
|
|
819
|
+
state: t?.state ?? null,
|
|
820
|
+
running: !!t?.running,
|
|
821
|
+
on: !!cfg.tailscale,
|
|
822
|
+
ask: cfg.tailscaleAsk !== false,
|
|
823
|
+
accept: cfg.tailscaleAccept !== false,
|
|
824
|
+
login: t?.self?.login ?? null,
|
|
825
|
+
addr: t?.self?.ips?.[0] ?? null,
|
|
826
|
+
exitNode: !!t?.exitNode,
|
|
827
|
+
// The owner's machines a beacon goes to right now.
|
|
828
|
+
devices: beaconTargets(t).length,
|
|
829
|
+
};
|
|
830
|
+
};
|
|
1135
831
|
|
|
1136
|
-
/**
|
|
1137
|
-
*
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1172
|
-
|
|
832
|
+
/** Every deck this one dials or is paired with, one row each — see
|
|
833
|
+
* status()'s `peers`. Asked only while the beacon is up. */
|
|
834
|
+
const deckRows = () => {
|
|
835
|
+
// ONE DECK, ONE ROW, and it takes work because a deck can arrive here
|
|
836
|
+
// twice by two different routes: heard on the network, and dialled at
|
|
837
|
+
// an address somebody typed or that an invite carried. Both are the
|
|
838
|
+
// same machine and neither knows it — the beacon row is keyed by the
|
|
839
|
+
// fingerprint it announced, the typed row by `host:port`, and until a
|
|
840
|
+
// connection succeeds nothing joins them.
|
|
841
|
+
//
|
|
842
|
+
// What joins them is `dials.metAt`: the fingerprint that actually
|
|
843
|
+
// answered at that address. So every row is given the identity it is
|
|
844
|
+
// really about, and rows that turn out to share one are merged — the
|
|
845
|
+
// heard half brings liveness, the dialled half brings the last round.
|
|
846
|
+
const rows = [];
|
|
847
|
+
const byId = new Map();
|
|
848
|
+
const put = row => {
|
|
849
|
+
const had = byId.get(row.id);
|
|
850
|
+
if (!had) { byId.set(row.id, row); rows.push(row); return; }
|
|
851
|
+
// Keep what each half is the authority on.
|
|
852
|
+
had.lastSeen = had.lastSeen ?? row.lastSeen;
|
|
853
|
+
had.last = had.last ?? row.last;
|
|
854
|
+
had.manual = had.manual || row.manual;
|
|
855
|
+
had.met = had.met || row.met;
|
|
856
|
+
if (row.name && !had.name) had.name = row.name;
|
|
857
|
+
};
|
|
858
|
+
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
859
|
+
// the logins it offered last, and when somebody here said yes. All
|
|
860
|
+
// three are keyed by the fingerprint that proved itself, so both
|
|
861
|
+
// halves of a merged row read the same answer.
|
|
862
|
+
const card = id => ({
|
|
863
|
+
...heardOf(id),
|
|
864
|
+
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
865
|
+
});
|
|
866
|
+
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
867
|
+
if (!stillListed(p, now())) continue;
|
|
868
|
+
const met = p.manual ? dials.metAt(`${p.addr}:${p.port}`) : null;
|
|
869
|
+
const id = met?.fp ?? p.fp;
|
|
870
|
+
put({
|
|
871
|
+
...p,
|
|
872
|
+
id,
|
|
873
|
+
// How it is reached. A heard row says which route its last beacon
|
|
874
|
+
// took; a typed one is read from its address.
|
|
875
|
+
via: viaOf(p),
|
|
876
|
+
// The fingerprint an unpair has to name. A typed row's own `fp` is
|
|
877
|
+
// a placeholder built from its address and matches nothing.
|
|
878
|
+
peerFp: p.manual ? met?.fp ?? null : p.fp,
|
|
879
|
+
name: met?.name || p.name,
|
|
880
|
+
met: !!met,
|
|
881
|
+
paired: !!trustedPeer(cfg.trusted, id),
|
|
882
|
+
last: lastRound.of(p.fp) ?? null,
|
|
883
|
+
...card(id),
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
|
887
|
+
// it, and nothing here has its address — which used to mean the panel
|
|
888
|
+
// listed failing addresses under "paired decks" and left out the one
|
|
889
|
+
// deck that actually was.
|
|
890
|
+
for (const t of cfg.trusted) {
|
|
891
|
+
if (byId.has(t.fp)) continue;
|
|
892
|
+
put({
|
|
893
|
+
id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
|
|
894
|
+
paired: true, waiting: true, last: lastRound.of(t.fp) ?? null,
|
|
895
|
+
// What it is to be "here" for a deck nothing dials: it called,
|
|
896
|
+
// and this is when. Undefined until it has, which is a row the
|
|
897
|
+
// panel draws as unknown rather than as live.
|
|
898
|
+
lastSeen: inbound.spokeAt(t.fp),
|
|
899
|
+
// Which way it called, once it has.
|
|
900
|
+
...(inbound.spokeFrom(t.fp) ? { via: viaAt(inbound.spokeFrom(t.fp)) } : {}),
|
|
901
|
+
// AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
|
|
902
|
+
// of those it is on. The card was kept and never handed over, so
|
|
903
|
+
// the dialog said "it runs an older version" about a deck that
|
|
904
|
+
// had just told it exactly which version it runs.
|
|
905
|
+
...card(t.fp),
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return rows;
|
|
1173
909
|
};
|
|
1174
910
|
|
|
1175
911
|
return {
|
|
1176
912
|
async apply(next) {
|
|
1177
|
-
/** The engine itself, for the callbacks handed to the socket below: they
|
|
1178
|
-
* outlive this call and `this` is not theirs to keep. */
|
|
1179
|
-
const self = this;
|
|
1180
913
|
engine = this;
|
|
1181
914
|
const was = cfg;
|
|
1182
915
|
cfg = { ...cfg, ...next };
|
|
1183
|
-
//
|
|
1184
|
-
// switch
|
|
1185
|
-
//
|
|
1186
|
-
|
|
1187
|
-
pending.clear();
|
|
1188
|
-
onChange?.();
|
|
1189
|
-
}
|
|
1190
|
-
// TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
|
|
1191
|
-
// this on with two rows sitting in the panel means those two as much as
|
|
1192
|
-
// the next one, and leaving them queued behind a setting called
|
|
1193
|
-
// "automatic" is the switch not doing what it says.
|
|
1194
|
-
//
|
|
1195
|
-
// PER ROUTE, because each pair of switches answers for its own: turning
|
|
1196
|
-
// the local one on does not answer a tailnet request, and turning the
|
|
1197
|
-
// Tailscale one on answers only the owner's own machines.
|
|
1198
|
-
const yes = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAccept, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAccept !== false });
|
|
1199
|
-
const ask = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAsk, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAsk !== false });
|
|
1200
|
-
const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
|
|
1201
|
-
const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
|
|
1202
|
-
for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
|
|
1203
|
-
// The same for the other direction: switching `ask` on with four machines
|
|
1204
|
-
// already listed asks those four.
|
|
1205
|
-
const mayAsk = p => (p.via === "tailscale" ? turnedOn(ask, "tailscale") && p.own : turnedOn(ask, "lan"));
|
|
1206
|
-
for (const [fp, p] of [...strangers]) if (!declined.has(fp) && !wasUnpaired(fp) && !p.pub && mayAsk(p)) this.accept(fp, { byHand: false });
|
|
1207
|
-
// OFF MEANS THE TAILNET GOES QUIET HERE: nobody heard over it is offered,
|
|
1208
|
-
// and syncTailnet stops the reads. Decks already paired stay paired.
|
|
1209
|
-
if (was.tailscale && !cfg.tailscale) {
|
|
1210
|
-
for (const [fp, p] of [...strangers]) if (p.via === "tailscale") strangers.delete(fp);
|
|
1211
|
-
}
|
|
916
|
+
// What the write does to the decks already waiting — invite-only clears
|
|
917
|
+
// the requests, a switch turned on answers them, the tailnet switch off
|
|
918
|
+
// forgets who was heard over it. See switched in lan-requests.mjs.
|
|
919
|
+
requests.switched(was);
|
|
1212
920
|
const restart = !was.enabled !== !cfg.enabled
|
|
1213
921
|
|| was.secret !== cfg.secret
|
|
1214
922
|
|| was.name !== cfg.name;
|
|
1215
|
-
if (!restart) {
|
|
923
|
+
if (!restart) { tailPoll.sync(); return; }
|
|
1216
924
|
this.stop(cfg.enabled);
|
|
1217
925
|
if (!cfg.enabled) return;
|
|
1218
926
|
const startedIn = generation;
|
|
@@ -1230,48 +938,25 @@ export function createEngine({
|
|
|
1230
938
|
const startingServer = createSyncServer({
|
|
1231
939
|
fp: identity.fp, pub: identity.pub, secret: identity.secret,
|
|
1232
940
|
name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
|
|
1233
|
-
// See inboundAt. Every connection passes here,
|
|
1234
|
-
// on to fail the handshake — a stranger who
|
|
1235
|
-
// still proved the path.
|
|
1236
|
-
onInbound:
|
|
941
|
+
// See inboundAt in lan-inbound.mjs. Every connection passes here,
|
|
942
|
+
// including one that goes on to fail the handshake — a stranger who
|
|
943
|
+
// cannot prove anything has still proved the path.
|
|
944
|
+
onInbound: inbound.arrived,
|
|
1237
945
|
trusted: () => cfg.trusted,
|
|
1238
|
-
invite:
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
onInviteUsed: entry => {
|
|
1242
|
-
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
1243
|
-
cfg = { ...cfg, trusted: list };
|
|
1244
|
-
markUnpaired(entry.fp, false);
|
|
1245
|
-
invite = null;
|
|
1246
|
-
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
1247
|
-
// deck with no way to reach it: an inbound connection puts nothing in
|
|
1248
|
-
// the dial list. Without this the pairing is mutual in the trusted
|
|
1249
|
-
// list and one-way in fact — and `addPeer` alone lives in memory, so
|
|
1250
|
-
// it would be one-way again after the next restart.
|
|
1251
|
-
if (entry.addr && entry.port) {
|
|
1252
|
-
this.addPeer(entry.addr, entry.port);
|
|
1253
|
-
onDial?.(`${entry.addr}:${entry.port}`);
|
|
1254
|
-
// AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
|
|
1255
|
-
// to a heard one, and it was only ever filled by a round that
|
|
1256
|
-
// succeeded — so between accepting a deck and the next round, one
|
|
1257
|
-
// machine appeared as two rows. We already know the answer here:
|
|
1258
|
-
// the handshake that just finished said so.
|
|
1259
|
-
learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
|
|
1260
|
-
}
|
|
1261
|
-
onTrust?.(list);
|
|
1262
|
-
onChange?.();
|
|
1263
|
-
},
|
|
946
|
+
invite: offer.live,
|
|
947
|
+
onInviteUsed: inviteUsed,
|
|
948
|
+
onWrongInvite: offer.wrongInvite,
|
|
1264
949
|
// Asked before the request is drawn, so a deck that was told no is
|
|
1265
950
|
// told no again rather than becoming a row somebody has to answer
|
|
1266
951
|
// twice. The socket sends the reason; this only knows the name.
|
|
1267
|
-
declined:
|
|
952
|
+
declined: requests.isDeclined,
|
|
1268
953
|
// Read on every handshake rather than captured, so switching the mode
|
|
1269
954
|
// takes effect on the next caller without restarting the listener.
|
|
1270
955
|
inviteOnly: () => cfg.pairingMode === "invite",
|
|
1271
956
|
// The same helper the outbound round uses, because a deck that called
|
|
1272
957
|
// in and a deck this one called have proved exactly the same thing —
|
|
1273
|
-
// see askToAccept.
|
|
1274
|
-
onPending: askToAccept,
|
|
958
|
+
// see askToAccept in lan-requests.mjs.
|
|
959
|
+
onPending: requests.askToAccept,
|
|
1275
960
|
});
|
|
1276
961
|
server = startingServer;
|
|
1277
962
|
let port;
|
|
@@ -1290,7 +975,7 @@ export function createEngine({
|
|
|
1290
975
|
stalled = null;
|
|
1291
976
|
// From here the socket is accepting, so this is the moment the silence
|
|
1292
977
|
// starts being about the network rather than about a deck still starting.
|
|
1293
|
-
|
|
978
|
+
inbound.listening();
|
|
1294
979
|
if (port !== cfg.port) onPort?.(port);
|
|
1295
980
|
const startingBeacon = createBeacon({
|
|
1296
981
|
port, name: cfg.name, fp: identity.fp,
|
|
@@ -1305,50 +990,9 @@ export function createEngine({
|
|
|
1305
990
|
// spell, behind the sentence that does not need the name.
|
|
1306
991
|
rebindMs: bindRetryMs,
|
|
1307
992
|
routes,
|
|
1308
|
-
onHearing:
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
holder = null;
|
|
1312
|
-
void Promise.resolve().then(() => portHolder()).then(who => { holder = who ?? null; onChange?.(); }, () => {});
|
|
1313
|
-
}
|
|
1314
|
-
onChange?.();
|
|
1315
|
-
},
|
|
1316
|
-
onStranger: entry => {
|
|
1317
|
-
const had = strangers.get(entry.fp);
|
|
1318
|
-
// KEYED BY MACHINE WHEN IT SAYS WHICH ONE IT IS. A computer that took
|
|
1319
|
-
// a fresh key — a second deck sharing one config directory does, by
|
|
1320
|
-
// design — used to leave its old key in this map for a day, and every
|
|
1321
|
-
// one of them drew a row offering to pair with the same machine.
|
|
1322
|
-
if (entry.host) for (const [fp, p] of strangers) if (p.host === entry.host && fp !== entry.fp) strangers.delete(fp);
|
|
1323
|
-
const own = entry.via === "tailscale" && !!routeTo(entry.addr)?.own;
|
|
1324
|
-
strangers.set(entry.fp, { ...entry, own });
|
|
1325
|
-
// ASK IT, which is what the `ask` verb on its row does and nothing
|
|
1326
|
-
// more: the address goes on the dial list and the next round sends a
|
|
1327
|
-
// request that somebody over there still has to answer. A beacon
|
|
1328
|
-
// carries a fingerprint and no key, so nothing is pinned here — see
|
|
1329
|
-
// accept, which is deliberate about the difference.
|
|
1330
|
-
//
|
|
1331
|
-
// Only a deck that is NEW is asked, or a beacon every thirty seconds
|
|
1332
|
-
// would be thirty seconds of asking; and never one this deck's owner
|
|
1333
|
-
// already turned away.
|
|
1334
|
-
// Never `byHand`: a beacon is a shout from an address nobody here
|
|
1335
|
-
// named, and the row it leaves may ask rather than pin.
|
|
1336
|
-
// Over the tailnet only a machine on this person's own account is
|
|
1337
|
-
// asked unprompted; any other is a row for somebody to decide on.
|
|
1338
|
-
const mayAsk = asksOn(entry.via) && (entry.via !== "tailscale" || own);
|
|
1339
|
-
if (mayAsk && !had && !declined.has(entry.fp) && !wasUnpaired(entry.fp)) { self.accept(entry.fp, { byHand: false }); return; }
|
|
1340
|
-
// Only a deck that is new to us is news. A beacon every thirty
|
|
1341
|
-
// seconds from one already on the list is not a reason to redraw.
|
|
1342
|
-
if (!had) onChange?.();
|
|
1343
|
-
},
|
|
1344
|
-
// Take a new key and keep it. Two decks with one identity are invisible
|
|
1345
|
-
// to each other forever otherwise, and the second one to notice moving
|
|
1346
|
-
// is enough — whichever notices first, moves.
|
|
1347
|
-
onIdClash: () => {
|
|
1348
|
-
const fresh = identityFrom("");
|
|
1349
|
-
onIdentity?.(fresh.secret);
|
|
1350
|
-
onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
|
|
1351
|
-
},
|
|
993
|
+
onHearing: hearing.hearingChanged,
|
|
994
|
+
onStranger: requests.heardStranger,
|
|
995
|
+
onIdClash: idClash,
|
|
1352
996
|
onError, now,
|
|
1353
997
|
...(createSocket ? { createSocket } : {}),
|
|
1354
998
|
});
|
|
@@ -1358,17 +1002,10 @@ export function createEngine({
|
|
|
1358
1002
|
// One read of the tailnet whatever the switch says, so a packet from a
|
|
1359
1003
|
// tailnet address is told apart from a local one from the first minute.
|
|
1360
1004
|
void tailnet?.freshen?.(TAILNET_IDLE_MS);
|
|
1361
|
-
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1365
|
-
try { await round(); } catch { /* a round reports itself, per peer */ }
|
|
1366
|
-
if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
|
|
1367
|
-
timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
|
|
1368
|
-
timer.unref?.();
|
|
1369
|
-
};
|
|
1370
|
-
timer = setTimeout(() => { void tick(); }, SYNC_MS);
|
|
1371
|
-
timer.unref?.();
|
|
1005
|
+
tailPoll.sync();
|
|
1006
|
+
// The first round a minute from now, and one after each that finishes,
|
|
1007
|
+
// for as long as this start stands — see lan-round-timer.mjs.
|
|
1008
|
+
roundTimer.start(() => startedIn !== generation || beacon !== startingBeacon || !cfg.enabled);
|
|
1372
1009
|
},
|
|
1373
1010
|
/**
|
|
1374
1011
|
* Make an invite: every address this deck has, its port, its name, and a
|
|
@@ -1387,25 +1024,14 @@ export function createEngine({
|
|
|
1387
1024
|
const addrs = localAddresses().map(a => `${a}:${port}`);
|
|
1388
1025
|
const made = mintInvite({ addrs, name: cfg.name, now: now() });
|
|
1389
1026
|
if (!made) return null;
|
|
1390
|
-
|
|
1391
|
-
onChange?.();
|
|
1027
|
+
offer.put(made);
|
|
1392
1028
|
return { token: made.token, expiresAt: made.expiresAt, addrs };
|
|
1393
1029
|
},
|
|
1394
1030
|
|
|
1395
|
-
/** What this deck is offering right now,
|
|
1396
|
-
*
|
|
1397
|
-
offering
|
|
1398
|
-
|
|
1399
|
-
return { token: invite.token, expiresAt: invite.expiresAt };
|
|
1400
|
-
},
|
|
1401
|
-
|
|
1402
|
-
/** Put it away without using it. */
|
|
1403
|
-
withdraw() {
|
|
1404
|
-
const had = !!invite;
|
|
1405
|
-
invite = null;
|
|
1406
|
-
if (had) onChange?.();
|
|
1407
|
-
return had;
|
|
1408
|
-
},
|
|
1031
|
+
/** What this deck is offering right now, and putting it away unused —
|
|
1032
|
+
* see lan-invite-offer.mjs. */
|
|
1033
|
+
offering: offer.offering,
|
|
1034
|
+
withdraw: offer.withdraw,
|
|
1409
1035
|
|
|
1410
1036
|
/**
|
|
1411
1037
|
* Join on somebody else's invite: try every address it carries until one
|
|
@@ -1446,14 +1072,8 @@ export function createEngine({
|
|
|
1446
1072
|
// The handshake can complete after LAN was switched off (or the
|
|
1447
1073
|
// identity was restarted). Never persist a pin from that old join.
|
|
1448
1074
|
if (!stillJoining()) return { ok: false, reason: "not_running", tried };
|
|
1449
|
-
const { list } =
|
|
1450
|
-
|
|
1451
|
-
});
|
|
1452
|
-
cfg = { ...cfg, trusted: list };
|
|
1453
|
-
markUnpaired(conn.peerFp, false);
|
|
1454
|
-
this.addPeer(at.addr, at.port);
|
|
1455
|
-
onDial?.(`${at.addr}:${at.port}`);
|
|
1456
|
-
learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
|
|
1075
|
+
const { list } = pin({ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name });
|
|
1076
|
+
keepDialling(at.addr, at.port, { fp: conn.peerFp, name: conn.peerName || inv.name });
|
|
1457
1077
|
onTrust?.(list);
|
|
1458
1078
|
onChange?.();
|
|
1459
1079
|
return {
|
|
@@ -1480,9 +1100,7 @@ export function createEngine({
|
|
|
1480
1100
|
* trusted on a name somebody typed.
|
|
1481
1101
|
*/
|
|
1482
1102
|
accept(fp, { byHand = true } = {}) {
|
|
1483
|
-
const
|
|
1484
|
-
const heard = strangers.get(fp) ?? null;
|
|
1485
|
-
const seen = asked ?? heard;
|
|
1103
|
+
const seen = requests.seen(fp);
|
|
1486
1104
|
if (!seen || cfg.pairingMode === "invite") return null;
|
|
1487
1105
|
if (wasUnpaired(fp) && !byHand) return null;
|
|
1488
1106
|
// TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
|
|
@@ -1506,15 +1124,12 @@ export function createEngine({
|
|
|
1506
1124
|
// deck answering a shout — and the row it leaves may only ASK, which is
|
|
1507
1125
|
// what the switch's own name says it does. See roundWith.
|
|
1508
1126
|
this.addPeer(seen.addr, seen.port, { typed: byHand });
|
|
1509
|
-
|
|
1127
|
+
requests.dropHeard(fp);
|
|
1510
1128
|
onChange?.();
|
|
1511
1129
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
1512
1130
|
}
|
|
1513
|
-
const { list, added } =
|
|
1514
|
-
|
|
1515
|
-
markUnpaired(fp, false);
|
|
1516
|
-
pending.delete(fp);
|
|
1517
|
-
strangers.delete(fp);
|
|
1131
|
+
const { list, added } = pin({ fp, pub: seen.pub, name: seen.name });
|
|
1132
|
+
requests.drop(fp);
|
|
1518
1133
|
onTrust?.(list);
|
|
1519
1134
|
onChange?.();
|
|
1520
1135
|
// AND DIAL IT BACK. Accepting a deck that called us made it welcome and
|
|
@@ -1522,41 +1137,20 @@ export function createEngine({
|
|
|
1522
1137
|
// dials, and an inbound connection puts nothing in it. So the pairing was
|
|
1523
1138
|
// mutual in the trusted list and one-way in fact — if the other machine
|
|
1524
1139
|
// stopped calling, nothing here would ever call it. The hello carries the
|
|
1525
|
-
// port it listens on for exactly this.
|
|
1140
|
+
// port it listens on for exactly this. AND KEPT, through onDial, as an
|
|
1141
|
+
// invite's is (#1643): `addPeer` alone lives in memory, and the next
|
|
1142
|
+
// settings write or restart made the pairing one-way again.
|
|
1526
1143
|
const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
|
|
1527
|
-
? (
|
|
1144
|
+
? (dials.meet(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
|
|
1145
|
+
onDial?.(`${seen.addr}:${seen.port}`),
|
|
1528
1146
|
{ addr: seen.addr, port: seen.port })
|
|
1529
1147
|
: null;
|
|
1530
1148
|
return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
|
|
1531
1149
|
},
|
|
1532
|
-
/** Say no
|
|
1533
|
-
*
|
|
1534
|
-
dismiss
|
|
1535
|
-
|
|
1536
|
-
// and address, and after the delete below there is nowhere else to read
|
|
1537
|
-
// them from.
|
|
1538
|
-
const was = pending.get(fp) ?? strangers.get(fp) ?? null;
|
|
1539
|
-
const had = pending.delete(fp) || strangers.delete(fp);
|
|
1540
|
-
if (had) {
|
|
1541
|
-
declined.set(fp, {
|
|
1542
|
-
fp,
|
|
1543
|
-
name: was?.name ?? fp,
|
|
1544
|
-
addr: was?.addr ?? "",
|
|
1545
|
-
port: was?.port ?? 0,
|
|
1546
|
-
at: now(),
|
|
1547
|
-
});
|
|
1548
|
-
onChange?.();
|
|
1549
|
-
}
|
|
1550
|
-
return had;
|
|
1551
|
-
},
|
|
1552
|
-
/** Change your mind. The name comes off the declined list and the next time
|
|
1553
|
-
* that deck dials, it is a request again — which it will, on its own, so
|
|
1554
|
-
* there is nothing else to press. */
|
|
1555
|
-
allow(fp) {
|
|
1556
|
-
const had = declined.delete(fp);
|
|
1557
|
-
if (had) onChange?.();
|
|
1558
|
-
return had;
|
|
1559
|
-
},
|
|
1150
|
+
/** Say no and stop being asked, and change your mind — see dismiss and
|
|
1151
|
+
* allow in lan-requests.mjs. */
|
|
1152
|
+
dismiss: requests.dismiss,
|
|
1153
|
+
allow: requests.allow,
|
|
1560
1154
|
/** Unpair. It stops what has not happened yet and takes back nothing that
|
|
1561
1155
|
* has — the same sentence the panel says about a shared login. */
|
|
1562
1156
|
unpair(fp) {
|
|
@@ -1577,8 +1171,9 @@ export function createEngine({
|
|
|
1577
1171
|
* Null when it is neither, which is a deck that only calls in — nothing
|
|
1578
1172
|
* here holds an address for it, so there is nobody to dial.
|
|
1579
1173
|
*
|
|
1580
|
-
* `roundAt` is left alone: it says when EVERY
|
|
1581
|
-
* and asking one of them does not make that
|
|
1174
|
+
* `roundAt` (see lan-round-record.mjs) is left alone: it says when EVERY
|
|
1175
|
+
* paired deck was last asked, and asking one of them does not make that
|
|
1176
|
+
* true.
|
|
1582
1177
|
*
|
|
1583
1178
|
* IN TURN, LIKE EVERY ROUND (#1132). This called `roundWith` straight, past
|
|
1584
1179
|
* the guard `round` keeps, and a press during the timer's round dialled the
|
|
@@ -1613,64 +1208,24 @@ export function createEngine({
|
|
|
1613
1208
|
async roundOne(fp) {
|
|
1614
1209
|
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
1615
1210
|
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
1616
|
-
const typed =
|
|
1211
|
+
const typed = dials.rowAnswering(fp);
|
|
1617
1212
|
const peer = heard ?? typed;
|
|
1618
1213
|
if (!peer) return null;
|
|
1619
|
-
const had = lastRound.
|
|
1620
|
-
await
|
|
1621
|
-
const got = lastRound.
|
|
1214
|
+
const had = lastRound.of(peer.fp);
|
|
1215
|
+
await turns.ahead();
|
|
1216
|
+
const got = lastRound.of(peer.fp);
|
|
1622
1217
|
if (got && got !== had) return got.done ?? [];
|
|
1623
1218
|
// A deck switched off while the press waited is not dialled after all.
|
|
1624
|
-
return inTurn(() => (beacon ? roundWith(peer) : []));
|
|
1625
|
-
},
|
|
1626
|
-
/** Dial this address on every round from now on. Returns false for an
|
|
1627
|
-
* address that is not one, rather than storing a row that can never
|
|
1628
|
-
* connect and reports an error every minute forever. */
|
|
1629
|
-
addPeer(addr, port, { typed = true } = {}) {
|
|
1630
|
-
const p = Number(port);
|
|
1631
|
-
if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
|
|
1632
|
-
const host = addr.trim();
|
|
1633
|
-
const at = `${host}:${p}`;
|
|
1634
|
-
// MAKING ROOM RATHER THAN REFUSING, and only among rows the deck added
|
|
1635
|
-
// itself. A hard refusal at the cap would let whoever got there first
|
|
1636
|
-
// keep the whole budget, so a real deck starting later would never be
|
|
1637
|
-
// asked — which turns a cap meant to protect the round into a way to
|
|
1638
|
-
// silence it. Evicted first is the oldest auto row that has never
|
|
1639
|
-
// answered: `learned` holds an entry only for an address that completed a
|
|
1640
|
-
// handshake, so a row with no entry there has cost a round and returned
|
|
1641
|
-
// nothing. When every auto row has answered, the new one waits.
|
|
1642
|
-
if (!typed && !manual.has(at)) {
|
|
1643
|
-
const auto = [...manual.entries()].filter(([, v]) => !v.typed);
|
|
1644
|
-
if (auto.length >= MAX_AUTO_PEERS) {
|
|
1645
|
-
const stale = auto.find(([k]) => !learned.has(k));
|
|
1646
|
-
if (!stale) return false;
|
|
1647
|
-
manual.delete(stale[0]);
|
|
1648
|
-
learned.delete(stale[0]);
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
// A row somebody typed outranks one the deck added: the same address
|
|
1652
|
-
// arriving by hand after a beacon put it there is a person vouching for
|
|
1653
|
-
// it, and nothing about that should be undone by the next beacon.
|
|
1654
|
-
const was = manual.get(at);
|
|
1655
|
-
manual.set(at, {
|
|
1656
|
-
fp: `manual:${at}`, name: host, addr: host, port: p, manual: true,
|
|
1657
|
-
typed: typed || was?.typed === true,
|
|
1658
|
-
});
|
|
1659
|
-
return true;
|
|
1660
|
-
},
|
|
1661
|
-
removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
|
|
1662
|
-
/** Replace the typed list wholesale, which is what a settings write means.
|
|
1663
|
-
* Adding one at a time would leave a removed address still being dialled
|
|
1664
|
-
* every minute until the next restart — the row would vanish from the
|
|
1665
|
-
* panel while the socket kept opening, which is the worst of both. */
|
|
1666
|
-
setPeers(entries) {
|
|
1667
|
-
manual.clear();
|
|
1668
|
-
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1669
|
-
const at = String(entry).lastIndexOf(":");
|
|
1670
|
-
if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
|
|
1671
|
-
}
|
|
1672
|
-
return manual.size;
|
|
1219
|
+
return turns.inTurn(() => (beacon ? roundWith(peer) : []));
|
|
1673
1220
|
},
|
|
1221
|
+
/** Dial this address on every round from now on; false for an address
|
|
1222
|
+
* that is not one. The list, its cap and why a typed row outranks one the
|
|
1223
|
+
* deck added are lan-dials.mjs's. */
|
|
1224
|
+
addPeer(addr, port, { typed = true } = {}) { return dials.add(addr, port, { typed }); },
|
|
1225
|
+
removePeer(addr, port) { return dials.remove(addr, port); },
|
|
1226
|
+
/** Replace the typed list wholesale, which is what a settings write means
|
|
1227
|
+
* — see replace in lan-dials.mjs. */
|
|
1228
|
+
setPeers(entries) { return dials.replace(entries); },
|
|
1674
1229
|
status() {
|
|
1675
1230
|
return {
|
|
1676
1231
|
enabled: !!cfg.enabled,
|
|
@@ -1678,14 +1233,15 @@ export function createEngine({
|
|
|
1678
1233
|
// Said only while it is true, and it is only ever true of a deck that
|
|
1679
1234
|
// is switched on and has no listener.
|
|
1680
1235
|
stalled: cfg.enabled && !beacon ? stalled : null,
|
|
1681
|
-
// Running, and unable to hear other decks announce — see deafLine
|
|
1682
|
-
|
|
1236
|
+
// Running, and unable to hear other decks announce — see deafLine in
|
|
1237
|
+
// lan-hearing.mjs.
|
|
1238
|
+
deaf: hearing.deafLine(),
|
|
1683
1239
|
// Every local broadcast held back, because this machine sends its
|
|
1684
1240
|
// local network through a tunnel — see leavesByTunnel.
|
|
1685
1241
|
lanTunneled: !!beacon?.tunneled?.(),
|
|
1686
1242
|
// When every paired deck was last asked. Null until the first round,
|
|
1687
1243
|
// which on a deck that has just started is the honest answer.
|
|
1688
|
-
checkedAt:
|
|
1244
|
+
checkedAt: lastRound.checkedAt(),
|
|
1689
1245
|
name: cfg.name,
|
|
1690
1246
|
// This deck's own card, so the panel can read a peer's version against
|
|
1691
1247
|
// it; and the names somebody here gave other decks, which the panel
|
|
@@ -1701,22 +1257,7 @@ export function createEngine({
|
|
|
1701
1257
|
shareActive: cfg.shareActive !== false,
|
|
1702
1258
|
// Discovery over Tailscale: whether this machine has it at all, which
|
|
1703
1259
|
// decides whether the dialog shows the switch, and what it can see.
|
|
1704
|
-
tailscale: tailnet ? (
|
|
1705
|
-
const t = tailnet.snapshot?.() ?? null;
|
|
1706
|
-
return {
|
|
1707
|
-
found: !!tailnet.found?.(),
|
|
1708
|
-
state: t?.state ?? null,
|
|
1709
|
-
running: !!t?.running,
|
|
1710
|
-
on: !!cfg.tailscale,
|
|
1711
|
-
ask: cfg.tailscaleAsk !== false,
|
|
1712
|
-
accept: cfg.tailscaleAccept !== false,
|
|
1713
|
-
login: t?.self?.login ?? null,
|
|
1714
|
-
addr: t?.self?.ips?.[0] ?? null,
|
|
1715
|
-
exitNode: !!t?.exitNode,
|
|
1716
|
-
// The owner's machines a beacon goes to right now.
|
|
1717
|
-
devices: beaconTargets(t).length,
|
|
1718
|
-
};
|
|
1719
|
-
})() : null,
|
|
1260
|
+
tailscale: tailnet ? tailnetStatus() : null,
|
|
1720
1261
|
fp: identity?.fp ?? null,
|
|
1721
1262
|
// The address and port a person on another subnet types into the other
|
|
1722
1263
|
// deck's field. Null when this machine has no ordinary one, which the
|
|
@@ -1724,139 +1265,49 @@ export function createEngine({
|
|
|
1724
1265
|
port: server?.port() ?? null,
|
|
1725
1266
|
// When another machine last got a connection through to this one. Null
|
|
1726
1267
|
// on a deck nobody has dialled yet, which is not the same as blocked
|
|
1727
|
-
// and is drawn as neither — see inboundAt
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
listeningSince
|
|
1268
|
+
// and is drawn as neither — see inboundAt in lan-inbound.mjs and
|
|
1269
|
+
// lan-reach.mjs.
|
|
1270
|
+
inboundAt: inbound.inboundAt(),
|
|
1271
|
+
// How long that null has been true for — see listeningSince there.
|
|
1272
|
+
listeningSince: inbound.listeningSince(),
|
|
1731
1273
|
addrs: beacon ? localAddresses() : [],
|
|
1732
1274
|
shared: [...cfg.shared],
|
|
1733
1275
|
// The token this deck is offering, if any. Drawn as the one thing to do
|
|
1734
|
-
// when nobody is paired yet, and put away once somebody is.
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1276
|
+
// when nobody is paired yet, and put away once somebody is. With how
|
|
1277
|
+
// many proofs of it have failed so far — the record beside the log's,
|
|
1278
|
+
// see wrongInvite in lan-invite-offer.mjs.
|
|
1279
|
+
invite: offer.row(),
|
|
1738
1280
|
// Decks somebody accepted, decks that asked and have not been answered,
|
|
1739
1281
|
// and decks merely heard. Three lists because they are three different
|
|
1740
1282
|
// things a person does something different about.
|
|
1741
1283
|
trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
|
|
1742
|
-
pending:
|
|
1284
|
+
pending: requests.pendingRows(),
|
|
1743
1285
|
// Only the ones somebody could actually pair with right now, one row
|
|
1744
1286
|
// per machine, newest first — see pairable, which is where the rule
|
|
1745
1287
|
// that keeps this from becoming a wall of ghosts lives.
|
|
1746
|
-
strangers: (
|
|
1747
|
-
// A deck that was told no is not somebody to offer pairing with. It
|
|
1748
|
-
// has its own row, with the one control that undoes the decision.
|
|
1749
|
-
const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
|
|
1750
|
-
// pairable() collapses the rest by machine — see hostId. A computer
|
|
1751
|
-
// that has run the deck a few times holds a key per run, and every
|
|
1752
|
-
// one of them was a row of its own on everybody else's panel.
|
|
1753
|
-
const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
|
|
1754
|
-
return shown.map(p => ({
|
|
1755
|
-
fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more,
|
|
1756
|
-
via: p.via ?? "lan", own: !!p.own,
|
|
1757
|
-
}));
|
|
1758
|
-
})(),
|
|
1288
|
+
strangers: requests.strangerRows(),
|
|
1759
1289
|
// Said no to, by somebody at this keyboard. Listed rather than merely
|
|
1760
1290
|
// silenced, because a refusal nobody can see is a refusal nobody can
|
|
1761
1291
|
// take back.
|
|
1762
|
-
declined:
|
|
1763
|
-
peers: beacon ? (
|
|
1764
|
-
// ONE DECK, ONE ROW, and it takes work because a deck can arrive here
|
|
1765
|
-
// twice by two different routes: heard on the network, and dialled at
|
|
1766
|
-
// an address somebody typed or that an invite carried. Both are the
|
|
1767
|
-
// same machine and neither knows it — the beacon row is keyed by the
|
|
1768
|
-
// fingerprint it announced, the typed row by `host:port`, and until a
|
|
1769
|
-
// connection succeeds nothing joins them.
|
|
1770
|
-
//
|
|
1771
|
-
// What joins them is `learned`: the fingerprint that actually
|
|
1772
|
-
// answered at that address. So every row is given the identity it is
|
|
1773
|
-
// really about, and rows that turn out to share one are merged — the
|
|
1774
|
-
// heard half brings liveness, the dialled half brings the last round.
|
|
1775
|
-
const rows = [];
|
|
1776
|
-
const byId = new Map();
|
|
1777
|
-
const put = row => {
|
|
1778
|
-
const had = byId.get(row.id);
|
|
1779
|
-
if (!had) { byId.set(row.id, row); rows.push(row); return; }
|
|
1780
|
-
// Keep what each half is the authority on.
|
|
1781
|
-
had.lastSeen = had.lastSeen ?? row.lastSeen;
|
|
1782
|
-
had.last = had.last ?? row.last;
|
|
1783
|
-
had.manual = had.manual || row.manual;
|
|
1784
|
-
had.met = had.met || row.met;
|
|
1785
|
-
if (row.name && !had.name) had.name = row.name;
|
|
1786
|
-
};
|
|
1787
|
-
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
1788
|
-
// the logins it offered last, and when somebody here said yes. All
|
|
1789
|
-
// three are keyed by the fingerprint that proved itself, so both
|
|
1790
|
-
// halves of a merged row read the same answer.
|
|
1791
|
-
const card = id => ({
|
|
1792
|
-
about: aboutBy.get(id) ?? null,
|
|
1793
|
-
offers: offersBy.get(id) ?? null,
|
|
1794
|
-
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1795
|
-
});
|
|
1796
|
-
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
1797
|
-
if (!stillListed(p, now())) continue;
|
|
1798
|
-
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
1799
|
-
const id = met?.fp ?? p.fp;
|
|
1800
|
-
put({
|
|
1801
|
-
...p,
|
|
1802
|
-
id,
|
|
1803
|
-
// How it is reached. A heard row says which route its last beacon
|
|
1804
|
-
// took; a typed one is read from its address.
|
|
1805
|
-
via: p.via ?? (routeTo(p.addr) ? "tailscale" : "lan"),
|
|
1806
|
-
// The fingerprint an unpair has to name. A typed row's own `fp` is
|
|
1807
|
-
// a placeholder built from its address and matches nothing.
|
|
1808
|
-
peerFp: p.manual ? met?.fp ?? null : p.fp,
|
|
1809
|
-
name: met?.name || p.name,
|
|
1810
|
-
met: !!met,
|
|
1811
|
-
paired: !!trustedPeer(cfg.trusted, id),
|
|
1812
|
-
last: lastRound.get(p.fp) ?? null,
|
|
1813
|
-
...card(id),
|
|
1814
|
-
});
|
|
1815
|
-
}
|
|
1816
|
-
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
|
1817
|
-
// it, and nothing here has its address — which used to mean the panel
|
|
1818
|
-
// listed failing addresses under "paired decks" and left out the one
|
|
1819
|
-
// deck that actually was.
|
|
1820
|
-
for (const t of cfg.trusted) {
|
|
1821
|
-
if (byId.has(t.fp)) continue;
|
|
1822
|
-
put({
|
|
1823
|
-
id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
|
|
1824
|
-
paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
|
|
1825
|
-
// What it is to be "here" for a deck nothing dials: it called,
|
|
1826
|
-
// and this is when. Undefined until it has, which is a row the
|
|
1827
|
-
// panel draws as unknown rather than as live.
|
|
1828
|
-
lastSeen: spokeAt.get(t.fp),
|
|
1829
|
-
// Which way it called, once it has.
|
|
1830
|
-
...(spokeFrom.has(t.fp) ? { via: routeTo(spokeFrom.get(t.fp)) ? "tailscale" : "lan" } : {}),
|
|
1831
|
-
// AND WHAT IT SAID WHEN IT CALLED — its card, its list, and which
|
|
1832
|
-
// of those it is on. The card was kept and never handed over, so
|
|
1833
|
-
// the dialog said "it runs an older version" about a deck that
|
|
1834
|
-
// had just told it exactly which version it runs.
|
|
1835
|
-
...card(t.fp),
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
return rows;
|
|
1839
|
-
})() : [],
|
|
1292
|
+
declined: requests.declinedRows(),
|
|
1293
|
+
peers: beacon ? deckRows() : [],
|
|
1840
1294
|
};
|
|
1841
1295
|
},
|
|
1842
1296
|
stop(restarting = false) {
|
|
1843
1297
|
generation++;
|
|
1844
1298
|
if (!restarting) session++;
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
tailTimer = null;
|
|
1849
|
-
holder = undefined;
|
|
1299
|
+
roundTimer.stop();
|
|
1300
|
+
tailPoll.stop();
|
|
1301
|
+
hearing.forget();
|
|
1850
1302
|
// A deliberate stop is not a fault, and the next start says its own.
|
|
1851
1303
|
if (!cfg.enabled) stalled = null;
|
|
1852
|
-
timer = null;
|
|
1853
1304
|
beacon?.stop();
|
|
1854
1305
|
server?.stop();
|
|
1855
1306
|
beacon = null;
|
|
1856
1307
|
server = null;
|
|
1857
1308
|
// Nothing is listening, so nothing is being silent AT anybody. Leaving
|
|
1858
1309
|
// this set would have the next start measure its quiet from the last one.
|
|
1859
|
-
|
|
1310
|
+
inbound.closed();
|
|
1860
1311
|
},
|
|
1861
1312
|
};
|
|
1862
1313
|
}
|