ccdeck 3.25.0 → 3.26.1

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.
@@ -0,0 +1,269 @@
1
+ // Finding the owner's other machines on Tailscale, for the LAN beacon to reach.
2
+ //
3
+ // A TAILNET HAS NO BROADCAST DOMAIN. Tailscale routes IP between nodes and
4
+ // forwards neither broadcast nor multicast (tailscale#1013, #11134, both open),
5
+ // so the beacon that finds a deck on the office Wi-Fi never reaches the same
6
+ // person's laptop at home. What a tailnet does have is a list: every node this
7
+ // machine can see, with its address, its owner and whether it is online. So
8
+ // discovery over Tailscale is the same beacon, sent to each address on that
9
+ // list instead of to a broadcast address — and the list is read, never guessed.
10
+ // Sweeping 100.64.0.0/10 would be four million addresses of which Tailscale
11
+ // delivers only the ones already listed.
12
+ //
13
+ // THE CLI, NOT THE DAEMON'S LOCAL API. The local API would save a process per
14
+ // read, and it is not reachable the same way on the three platforms: on Windows
15
+ // it is a named pipe that refuses a Node client ("Unable to impersonate using a
16
+ // named pipe until data has been read from that pipe", measured on 1.102.3), and
17
+ // on the standalone macOS app its token file is readable by admin-group users
18
+ // only. `tailscale status --json` answers on all three, from a plain user, in
19
+ // 45-70 ms measured on macOS and Windows.
20
+ //
21
+ // ONLY THE OWNER'S OWN MACHINES. Paired decks exchange the logins somebody
22
+ // ticked, and a tailnet can hold a colleague's laptop, a tagged server, or a
23
+ // node somebody else shared in. A node counts as this person's only when it is
24
+ // signed in to the same Tailscale user as this one, carries no tag, and was not
25
+ // shared in — the same test Taildrop uses before it offers a node as a target.
26
+ import { existsSync } from "node:fs";
27
+ import { posix as posixPath, win32 as winPath } from "node:path";
28
+ import { run as runCommand } from "./exec.mjs";
29
+
30
+ /** How often the list is read while the switch is on — the beacon's own
31
+ * interval, so a machine that comes online is announced to on the next
32
+ * beacon rather than one after it. */
33
+ export const TAILNET_MS = 30_000;
34
+
35
+ /** How old a read may be while the switch is off. Off, the list is only there
36
+ * to say whether Tailscale is on this machine at all and to tell a tailnet
37
+ * address from a LAN one, and neither is worth a process every half minute. */
38
+ export const IDLE_MS = 5 * 60_000;
39
+
40
+ /** One read may not hold anything up for longer than this. A healthy one is
41
+ * under a tenth of a second; a daemon that does not answer is not going to. */
42
+ const READ_MS = 4_000;
43
+
44
+ /** The operating systems a deck runs on, spelled the way Tailscale reports
45
+ * them. A phone or a TV on the tailnet is online and can never answer, and a
46
+ * beacon to it wakes its tunnel for nothing. */
47
+ const DECK_OS = new Set(["linux", "macOS", "windows"]);
48
+
49
+ /**
50
+ * Where the CLI may be, best first, for one platform.
51
+ *
52
+ * macOS ships it inside the app bundle — App Store and standalone builds alike —
53
+ * and puts nothing on PATH unless the owner pressed "Install CLI" in the app's
54
+ * settings. A deck started at login gets launchd's PATH, which has none of
55
+ * these directories, so the bundle path goes first. Homebrew's open-source
56
+ * daemon is the other install.
57
+ *
58
+ * Windows installs to Program Files unless somebody chose another folder, and
59
+ * the bare name last lets PATH find that other folder.
60
+ *
61
+ * Built with the asked-about platform's own path module rather than node:path's
62
+ * bound one, because the suite asks for the Windows answer from macOS.
63
+ */
64
+ export function tailscaleCandidates(platform = process.platform, env = process.env) {
65
+ if (platform === "win32") {
66
+ const roots = [env.ProgramFiles, env.ProgramW6432, "C:\\Program Files"].filter(Boolean);
67
+ const out = roots.map(root => winPath.join(root, "Tailscale", "tailscale.exe"));
68
+ return [...new Set([...out, "tailscale"])];
69
+ }
70
+ if (platform === "darwin") {
71
+ return [
72
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
73
+ "/opt/homebrew/bin/tailscale",
74
+ "/usr/local/bin/tailscale",
75
+ "tailscale",
76
+ ];
77
+ }
78
+ // The deb and rpm packages install /usr/bin; the static tarball is unpacked
79
+ // wherever its owner put it, which PATH then has to know.
80
+ return ["/usr/bin/tailscale", "/usr/local/bin/tailscale", "tailscale"];
81
+ }
82
+
83
+ /**
84
+ * The environment the CLI is run with.
85
+ *
86
+ * THE macOS BINARY IS ALSO THE APP. With no TERM, TERM_PROGRAM, SHLVL or PS1 it
87
+ * decides it was double-clicked, prints "The Tailscale GUI failed to start" to
88
+ * stdout and exits 0 — measured with a launchd-shaped environment, which is
89
+ * exactly what a deck started at login has. `TAILSCALE_BE_CLI` is Tailscale's
90
+ * own switch for this, and TERM covers a build older than that switch.
91
+ */
92
+ export function cliEnv(env = process.env) {
93
+ return { ...env, TAILSCALE_BE_CLI: "1", TERM: env.TERM || "dumb" };
94
+ }
95
+
96
+ /** A node's IPv4 tailnet addresses. The beacon socket is udp4, and a node with
97
+ * only IPv6 on an IPv6-only tailnet is not somewhere it can send. */
98
+ function ipv4s(list) {
99
+ return (Array.isArray(list) ? list : []).filter(a => typeof a === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(a));
100
+ }
101
+
102
+ /** What to call a node: the first label of its MagicDNS name, which is unique
103
+ * on the tailnet, or its hostname, which is not. */
104
+ function nodeName(n) {
105
+ const dns = typeof n?.DNSName === "string" ? n.DNSName.split(".")[0] : "";
106
+ return dns || (typeof n?.HostName === "string" ? n.HostName : "");
107
+ }
108
+
109
+ /**
110
+ * `tailscale status --json`, as much of it as this feature reads.
111
+ *
112
+ * The CLI's help says the format "has changed between releases and might change
113
+ * more", so everything is read leniently: a missing field is false, an unknown
114
+ * one is ignored. Null for anything that is not a status document at all —
115
+ * the macOS GUI message above, a version warning, an empty read.
116
+ *
117
+ * `--json` exits 0 while Tailscale is stopped or signed out, so `running` is
118
+ * the backend's own word for it, never the exit code.
119
+ */
120
+ export function readTailnet(raw) {
121
+ let doc = raw;
122
+ if (typeof raw === "string") {
123
+ try { doc = JSON.parse(raw); } catch { return null; }
124
+ }
125
+ if (!doc || typeof doc !== "object" || typeof doc.BackendState !== "string") return null;
126
+ const self = doc.Self && typeof doc.Self === "object" ? doc.Self : {};
127
+ const users = doc.User && typeof doc.User === "object" ? doc.User : {};
128
+ // A MACHINE LOGGED IN WITH A TAG KEY OWNS NOTHING. Its user is the shared
129
+ // "tagged-devices" identity, so "same user as me" would be true of every
130
+ // tagged node on the tailnet.
131
+ const selfTagged = Array.isArray(self.Tags) && self.Tags.length > 0;
132
+ const me = self.UserID;
133
+ const peers = Object.values(doc.Peer && typeof doc.Peer === "object" ? doc.Peer : {})
134
+ .filter(p => p && typeof p === "object")
135
+ .map(p => ({
136
+ name: nodeName(p),
137
+ os: typeof p.OS === "string" ? p.OS : "",
138
+ ips: ipv4s(p.TailscaleIPs),
139
+ online: p.Online === true,
140
+ expired: p.Expired === true,
141
+ own: !selfTagged && me != null && p.UserID === me
142
+ && !(Array.isArray(p.Tags) && p.Tags.length > 0)
143
+ && p.ShareeNode !== true,
144
+ }));
145
+ return {
146
+ state: doc.BackendState,
147
+ running: doc.BackendState === "Running",
148
+ // Whether this machine sends its traffic through an exit node — which,
149
+ // without local network access, takes the local network with it.
150
+ exitNode: !!(doc.ExitNodeStatus && typeof doc.ExitNodeStatus === "object"),
151
+ self: {
152
+ name: nodeName(self),
153
+ ips: ipv4s(self.TailscaleIPs ?? doc.TailscaleIPs),
154
+ login: typeof users[me]?.LoginName === "string" ? users[me].LoginName : null,
155
+ tagged: selfTagged,
156
+ },
157
+ peers,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Where a beacon goes on this tailnet: the owner's own machines that are
163
+ * online and could be running a deck, one address each.
164
+ *
165
+ * Offline nodes are skipped rather than tried — a packet to one is relayed to
166
+ * Tailscale's DERP server and dropped there. The list can include this
167
+ * machine's own addresses only if something is badly wrong, and they are
168
+ * filtered anyway: a deck hears its own broadcast already.
169
+ */
170
+ export function beaconTargets(tailnet) {
171
+ if (!tailnet?.running || tailnet.self?.tagged) return [];
172
+ const mine = new Set(tailnet.self?.ips ?? []);
173
+ const out = [];
174
+ for (const p of tailnet.peers ?? []) {
175
+ if (!p.own || !p.online || p.expired || !DECK_OS.has(p.os)) continue;
176
+ const at = p.ips[0];
177
+ if (at && !mine.has(at) && !out.includes(at)) out.push(at);
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /** 100.64.0.0/10, the block Tailscale draws every IPv4 node address from. */
183
+ function inTailnetBlock(addr) {
184
+ const m = /^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(addr);
185
+ return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127;
186
+ }
187
+
188
+ /**
189
+ * Whether a packet from `addr` came over the tailnet, and from whom.
190
+ *
191
+ * Null means the local network, which is every answer this deck gave before
192
+ * Tailscale was read — and still the answer on a machine where it is not
193
+ * running.
194
+ *
195
+ * WHAT MAKES THE ANSWER SAFE TO ACT ON is Tailscale, not the packet: WireGuard
196
+ * binds each node address to that node's key, and tailscaled drops a packet
197
+ * whose source is not the node it came from, so a peer cannot claim another
198
+ * peer's address. A node the list does not name yet — it joined since the last
199
+ * read — is still a tailnet address when it is in Tailscale's block and this
200
+ * machine is on one, and it is nobody's own until the list says so.
201
+ */
202
+ export function routeOf(tailnet, addr) {
203
+ if (!tailnet?.running) return null;
204
+ const at = String(addr ?? "").replace(/^::ffff:/, "");
205
+ if (!at) return null;
206
+ const node = (tailnet.peers ?? []).find(p => p.ips.includes(at));
207
+ if (node) return { via: "tailscale", own: node.own, name: node.name };
208
+ return inTailnetBlock(at) ? { via: "tailscale", own: false, name: "" } : null;
209
+ }
210
+
211
+ /**
212
+ * The reader: finds the CLI once, reads the list on demand, and keeps the last
213
+ * answer for everything that has to decide synchronously — a beacon arriving,
214
+ * a request to accept.
215
+ *
216
+ * `found` is whether a Tailscale CLI exists on this machine at all, which is
217
+ * what decides whether the dialog shows the switch. A CLI that runs and cannot
218
+ * reach its daemon is found, with no tailnet.
219
+ */
220
+ export function createTailnet({
221
+ run = runCommand,
222
+ platform = process.platform,
223
+ env = process.env,
224
+ exists = existsSync,
225
+ now = Date.now,
226
+ } = {}) {
227
+ /** The candidate that answered, false once none did, null before looking. */
228
+ let bin = null;
229
+ let tailnet = null;
230
+ let readAt = 0;
231
+ let inFlight = null;
232
+
233
+ const isAbsolute = file => (platform === "win32" ? winPath : posixPath).isAbsolute(file);
234
+
235
+ const readOnce = async () => {
236
+ const tries = bin ? [bin] : tailscaleCandidates(platform, env).filter(c => !isAbsolute(c) || exists(c));
237
+ let ran = false;
238
+ for (const file of tries) {
239
+ const r = await run(file, ["status", "--json"], { timeout: READ_MS, maxBuffer: 32 << 20, env: cliEnv(env) });
240
+ if (r?.code === "ENOENT") continue;
241
+ ran = true;
242
+ // The document goes to stdout whatever the exit code; a daemon that is
243
+ // not running says so on stderr and leaves stdout empty.
244
+ const read = readTailnet(r?.stdout ?? "");
245
+ bin = file;
246
+ return read;
247
+ }
248
+ if (!ran) bin = false;
249
+ return null;
250
+ };
251
+
252
+ const refresh = () => {
253
+ inFlight ??= readOnce()
254
+ .then(read => { tailnet = read; }, () => { tailnet = null; })
255
+ .finally(() => { readAt = now(); inFlight = null; });
256
+ return inFlight;
257
+ };
258
+
259
+ return {
260
+ refresh,
261
+ /** Read again only if the last read is older than `maxAge`. */
262
+ freshen(maxAge) {
263
+ if (inFlight || (readAt && now() - readAt < maxAge)) return inFlight ?? Promise.resolve();
264
+ return refresh();
265
+ },
266
+ snapshot: () => tailnet,
267
+ found: () => !!bin,
268
+ };
269
+ }