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.
- package/bin/agent-dag.js +23 -1
- package/dist/web/assets/{BrowserWatchModal-Chcet-7r.js → BrowserWatchModal-CnAbmyRH.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BIdS0lCL.js → UsageHistoryModal-BCpieMjP.js} +1 -1
- package/dist/web/assets/index-CO8B_qBe.js +297 -0
- package/dist/web/assets/{index-7a7ISatq.css → index-DlaHgzye.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/app-host.mjs +37 -0
- package/src/server/block-notify.mjs +106 -32
- package/src/server/browser-react.mjs +16 -1
- package/src/server/ccusage.mjs +33 -1
- package/src/server/deck-prefs.mjs +16 -1
- package/src/server/index.mjs +67 -5
- package/src/server/installer.mjs +4 -1
- package/src/server/lan-engine.mjs +193 -16
- package/src/server/lan-reach.mjs +26 -8
- package/src/server/lan-socket.mjs +250 -67
- package/src/server/lan-sync.mjs +15 -2
- package/src/server/login-service.mjs +4 -0
- package/src/server/port-holder.mjs +66 -0
- package/src/server/route-via.mjs +109 -0
- package/src/server/self-update.mjs +6 -2
- package/src/server/tailscale.mjs +269 -0
- package/dist/web/assets/index-BoR2dMqR.js +0 -297
|
@@ -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
|
+
}
|