windscout 0.7.0 → 0.9.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 +89 -16
- package/dist/background.js +131 -0
- package/dist/cli.js +180 -121
- package/dist/config.js +44 -5
- package/dist/docker.js +123 -25
- package/dist/measure.js +12 -0
- package/dist/probe.js +50 -24
- package/dist/scan.js +8 -16
- package/dist/time.js +5 -0
- package/dist/tui/connect.js +29 -0
- package/dist/tui/picker.js +80 -63
- package/dist/tui/progress.js +6 -4
- package/dist/tui/report.js +14 -4
- package/dist/tui/results.js +454 -0
- package/dist/tui/text.js +19 -0
- package/dist/watch.js +454 -0
- package/dist/windscribe.js +58 -10
- package/dist/worker.js +8 -6
- package/package.json +1 -1
package/dist/docker.js
CHANGED
|
@@ -29,7 +29,7 @@ const REGISTRIES = [...(process.env.WINDSCOUT_REGISTRY_MIRRORS?.split(",").map((
|
|
|
29
29
|
// openresolv: the app sets the tunnel's DNS through resolvconf; without it, DNS goes nowhere once connected.
|
|
30
30
|
// The CMD first checks the kernel (WSL's builds tun and parts of nftables as modules): no tun or nftables, no tunnel, so
|
|
31
31
|
// the container stops saying so instead of scanning. The helper's WireGuard routing also puts a fib rule in an inet table,
|
|
32
|
-
// which Docker Desktop's own kernel (macOS, Windows without WSL) can't run: WS_WIREGUARD=0 then, and
|
|
32
|
+
// which Docker Desktop's own kernel (macOS, Windows without WSL) can't run: WS_WIREGUARD=0 then, and a parallel scan skips it.
|
|
33
33
|
// No systemd in a container: start the helper (root) like its unit would; the worker starts the app once it has the session
|
|
34
34
|
const DOCKERFILE = `FROM ${BASE_TAG}
|
|
35
35
|
ENV LANG=C.UTF-8
|
|
@@ -67,10 +67,11 @@ export const INSTALL_DOCS = {
|
|
|
67
67
|
win32: "https://docs.docker.com/desktop/setup/install/windows-install/",
|
|
68
68
|
}[process.platform] ?? "https://docs.docker.com/engine/install/";
|
|
69
69
|
const lastLine = (s) => s.trim().split("\n").at(-1)?.trim() ?? "";
|
|
70
|
-
/** Runs a command (a shell line when args is null); onLine gets its output as it comes, curl's progress too. code -1: not
|
|
71
|
-
|
|
70
|
+
/** Runs a command (a shell line when args is null); onLine gets its output as it comes, curl's progress too. code -1: not
|
|
71
|
+
* installed (or aborted) */
|
|
72
|
+
function exec(cmd, args, { input, onLine = () => { }, timeout, signal } = {}) {
|
|
72
73
|
return new Promise((resolve) => {
|
|
73
|
-
const p = args ? spawn(cmd, args, { timeout }) : spawn(cmd, { shell: true, timeout });
|
|
74
|
+
const p = args ? spawn(cmd, args, { timeout, signal }) : spawn(cmd, { shell: true, timeout, signal });
|
|
74
75
|
let out = "";
|
|
75
76
|
const take = (d) => {
|
|
76
77
|
out = (out + d).slice(-20_000);
|
|
@@ -92,23 +93,25 @@ async function docker(args, options) {
|
|
|
92
93
|
process.env.PATH = dirname(DESKTOP_CLI) + delimiter + process.env.PATH;
|
|
93
94
|
return exec("docker", args, options);
|
|
94
95
|
}
|
|
95
|
-
let info = { cpus: 0, memory: 0, os: "linux" }; // docker's, from the last dockerState() that found it ready
|
|
96
|
+
let info = { cpus: 0, memory: 0, os: "linux", name: "" }; // docker's, from the last dockerState() that found it ready
|
|
96
97
|
/** ready: the daemon answers us. denied: it runs, but this user may not use it (Linux: not in the docker group yet) */
|
|
97
98
|
export async function dockerState() {
|
|
98
|
-
const r = await docker(["info", "--format", "{{.NCPU}} {{.MemTotal}} {{.OSType}}"], { timeout: 30_000 });
|
|
99
|
+
const r = await docker(["info", "--format", "{{.NCPU}} {{.MemTotal}} {{.OSType}} {{.OperatingSystem}}"], { timeout: 30_000 });
|
|
99
100
|
if (r.code === -1)
|
|
100
101
|
return DESKTOP && existsSync(DESKTOP) ? "stopped" : "missing";
|
|
101
102
|
if (r.code)
|
|
102
103
|
return /permission denied/i.test(r.out) ? "denied" : "stopped";
|
|
103
|
-
const [cpus, memory, os] = r.out.trim().split(/\s+/);
|
|
104
|
-
info = { cpus: Number(cpus), memory: Number(memory), os };
|
|
104
|
+
const [cpus, memory, os, ...name] = r.out.trim().split(/\s+/);
|
|
105
|
+
info = { cpus: Number(cpus), memory: Number(memory), os, name: name.join(" ") };
|
|
105
106
|
return "ready";
|
|
106
107
|
}
|
|
108
|
+
/** Docker runs on this computer's own Linux kernel, as docker does on Linux, not in Docker Desktop's VM */
|
|
109
|
+
export const nativeDocker = () => process.platform === "linux" && !/Docker Desktop/i.test(info.name);
|
|
107
110
|
const WORKER_MEMORY = 250e6; // bytes per container: Windscribe's app and helper, a tunnel and node take ~120 MB
|
|
108
111
|
// ponytail: past this, more containers mostly add load on the one uplink, and WireGuard keys on the account; raise if real scans say so
|
|
109
112
|
const MAX_WORKERS = 16;
|
|
110
|
-
/** Protocols the containers can't do, which a parallel scan
|
|
111
|
-
*
|
|
113
|
+
/** Protocols the containers can't do, which a parallel scan skips: IKEv2 (Windscribe's Linux app has none), and WireGuard
|
|
114
|
+
* when their kernel can't run its routing (see DOCKERFILE) */
|
|
112
115
|
export const notInContainers = (protocols, wireguard) => new Set(protocols.filter((p) => /^ikev2(:|$)/.test(p) || (!wireguard && /^wireguard(:|$)/.test(p))));
|
|
113
116
|
/** Containers for this many attempts, when docker can run at most `most`: one per attempt; past that, the fewest that need no
|
|
114
117
|
* more rounds (17 attempts, 16 at most: 9 containers make 2 rounds, as 16 would), since fewer log in sooner and register fewer keys */
|
|
@@ -207,7 +210,7 @@ function fingerprint() {
|
|
|
207
210
|
const answers = (registry) => fetch(`https://${registry === "docker.io" ? "registry-1.docker.io" : registry}/v2/`, { signal: AbortSignal.timeout(8000) })
|
|
208
211
|
.then((r) => r.status === 200 || r.status === 401, () => false);
|
|
209
212
|
/** Gets the image's base: from the first registry in REGISTRIES that answers and serves it */
|
|
210
|
-
async function pullBase(onLine) {
|
|
213
|
+
async function pullBase(onLine, signal) {
|
|
211
214
|
if (!(await docker(["image", "inspect", BASE_TAG])).code)
|
|
212
215
|
return;
|
|
213
216
|
const up = await Promise.all(REGISTRIES.map(answers)); // asking them all at once: dead ones would each take the timeout
|
|
@@ -217,24 +220,29 @@ async function pullBase(onLine) {
|
|
|
217
220
|
for (const registry of order) {
|
|
218
221
|
onLine(`the base image, from ${registry}`);
|
|
219
222
|
const ref = `${registry}/${BASE}`;
|
|
220
|
-
const r = await docker(["pull", ref], { onLine, timeout: 15 * 60_000 });
|
|
223
|
+
const r = await docker(["pull", ref], { onLine, timeout: 15 * 60_000, signal });
|
|
224
|
+
if (signal?.aborted)
|
|
225
|
+
throw new Error("stopped");
|
|
221
226
|
if (!r.code)
|
|
222
227
|
return void (await docker(["tag", ref, BASE_TAG]));
|
|
223
228
|
why = `${registry}: ${lastLine(r.out)}`;
|
|
224
229
|
}
|
|
225
230
|
throw new Error(`couldn't get the containers' base image from Docker Hub or its mirrors (${why}). A VPN may help`);
|
|
226
231
|
}
|
|
227
|
-
/** Builds the containers' image, unless this windscout's is there already; onLine gets the build's output
|
|
228
|
-
|
|
232
|
+
/** Builds the containers' image, unless this windscout's is there already; onLine gets the build's output. Aborting signal
|
|
233
|
+
* stops the build (it throws) */
|
|
234
|
+
export async function buildImage(onLine, signal) {
|
|
229
235
|
if (info.os === "windows")
|
|
230
236
|
throw new Error("Docker Desktop runs Windows containers: switch it to Linux containers (right-click its icon by the clock), then try again");
|
|
231
237
|
const tag = fingerprint();
|
|
232
238
|
const have = await docker(["image", "inspect", "-f", '{{index .Config.Labels "windscout"}}', IMAGE]);
|
|
233
239
|
if (!have.code && have.out.trim() === tag)
|
|
234
240
|
return;
|
|
235
|
-
await pullBase(onLine);
|
|
241
|
+
await pullBase(onLine, signal);
|
|
236
242
|
// no --progress=plain: when piped, BuildKit prints plain anyway, and a distribution's docker may have only the old builder
|
|
237
|
-
const r = await docker(["build", "--label", `windscout=${tag}`, "-t", IMAGE, "-f", "-", fileURLToPath(ROOT).replace(/[\\/]$/, "")], { input: DOCKERFILE, onLine });
|
|
243
|
+
const r = await docker(["build", "--label", `windscout=${tag}`, "-t", IMAGE, "-f", "-", fileURLToPath(ROOT).replace(/[\\/]$/, "")], { input: DOCKERFILE, onLine, signal });
|
|
244
|
+
if (signal?.aborted)
|
|
245
|
+
throw new Error("stopped");
|
|
238
246
|
if (r.code)
|
|
239
247
|
throw new Error(`building the containers' image failed: ${lastLine(r.out)}`);
|
|
240
248
|
}
|
|
@@ -250,16 +258,27 @@ const running = new Map(); // container name → its `docker run`, until the con
|
|
|
250
258
|
const removeAll = () => running.size && spawnSync("docker", ["rm", "-f", ...running.keys()], { stdio: "ignore" });
|
|
251
259
|
process.on("exit", removeAll);
|
|
252
260
|
/** Starts n containers, their apps logged in with this computer's session; resolves to a prober for each container that got
|
|
253
|
-
* ready, and whether WireGuard works in them
|
|
254
|
-
|
|
261
|
+
* ready, and whether WireGuard works in them. network: the docker network they go on (docker's default bridge without);
|
|
262
|
+
* tag tells their names apart from another group's; aborting signal removes them (none gets ready then) */
|
|
263
|
+
export async function startWorkers(n, session, onStatus, { network, tag = "", signal } = {}) {
|
|
255
264
|
const measuring = mutex();
|
|
256
265
|
let wireguard = true;
|
|
257
266
|
let ready = 0;
|
|
267
|
+
const names = Array.from({ length: n }, (_, i) => `windscout-${process.pid}-${tag}${i + 1}`);
|
|
268
|
+
const remove = () => {
|
|
269
|
+
const left = names.filter((name) => running.has(name));
|
|
270
|
+
if (left.length)
|
|
271
|
+
spawn("docker", ["rm", "-f", ...left], { stdio: "ignore" }).on("error", () => { });
|
|
272
|
+
};
|
|
273
|
+
signal?.addEventListener("abort", remove, { once: true });
|
|
258
274
|
const start = (i) => new Promise((resolve) => {
|
|
259
|
-
|
|
275
|
+
if (signal?.aborted)
|
|
276
|
+
return resolve(null);
|
|
277
|
+
const name = names[i];
|
|
260
278
|
// its own volume keeps its app's settings for the next scans: its WireGuard keys, registered with Windscribe once
|
|
261
279
|
// it makes /dev/net/tun itself: Docker's Linux VM may have none (WSL loads tun as a module), and --device needs one there
|
|
262
|
-
const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name,
|
|
280
|
+
const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name, ...(network ? ["--network", network] : []),
|
|
281
|
+
"--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
|
|
263
282
|
"--sysctl", "net.ipv4.conf.all.src_valid_mark=1", "-v", `windscout-${i + 1}:/home/ws/.config/Windscribe`, IMAGE], { detached: process.platform !== "win32" }); // its own process group, so ctrl-c reaches only us (Windows would open a window)
|
|
264
283
|
running.set(name, p);
|
|
265
284
|
const send = (m) => p.stdin.write(JSON.stringify(m) + "\n");
|
|
@@ -288,7 +307,7 @@ export async function startWorkers(n, session, onStatus) {
|
|
|
288
307
|
};
|
|
289
308
|
p.on("error", (e) => gone(e.message));
|
|
290
309
|
p.on("close", (code) => gone(lastLine(err) || `exited (${code})`));
|
|
291
|
-
const prober = (query, protocol, { timeout, speedTest, steady =
|
|
310
|
+
const prober = (query, protocol, { timeout, speedTest, steady = 0, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
|
|
292
311
|
if (dead)
|
|
293
312
|
return reject(new Error(`container ${i + 1} stopped: ${dead}`));
|
|
294
313
|
let leave = null, over = false;
|
|
@@ -313,18 +332,44 @@ export async function startWorkers(n, session, onStatus) {
|
|
|
313
332
|
};
|
|
314
333
|
send({ probe: { query, protocol, timeout, speedTest, steady } });
|
|
315
334
|
});
|
|
335
|
+
const whoami = () => new Promise((done) => {
|
|
336
|
+
if (dead)
|
|
337
|
+
return done("");
|
|
338
|
+
const late = setTimeout(() => done(""), 15_000);
|
|
339
|
+
fail = () => (clearTimeout(late), done(""));
|
|
340
|
+
on = (m) => typeof m.ip === "string" && (clearTimeout(late), done(m.ip));
|
|
341
|
+
send({ whoami: true });
|
|
342
|
+
});
|
|
316
343
|
fail = (why) => (onStatus(`container ${i + 1}: ${why}`), resolve(null));
|
|
317
344
|
on = (m) => {
|
|
318
|
-
if (!m.ready)
|
|
345
|
+
if (!m.ready || signal?.aborted)
|
|
319
346
|
return;
|
|
320
347
|
wireguard &&= m.wireguard !== false;
|
|
321
348
|
fail = () => { };
|
|
322
349
|
onStatus(`${++ready} of ${n} ready`);
|
|
323
|
-
resolve(prober);
|
|
350
|
+
resolve({ name, prober, whoami, alive: () => !dead });
|
|
324
351
|
};
|
|
325
352
|
});
|
|
326
|
-
const
|
|
327
|
-
|
|
353
|
+
const workers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((w) => w !== null);
|
|
354
|
+
signal?.removeEventListener("abort", remove);
|
|
355
|
+
if (signal?.aborted)
|
|
356
|
+
remove(); // got ready as it was aborted
|
|
357
|
+
return {
|
|
358
|
+
probers: workers.map((w) => w.prober),
|
|
359
|
+
wireguard,
|
|
360
|
+
whoami: async () => (await workers.find((w) => w.alive())?.whoami()) ?? "",
|
|
361
|
+
alive: () => workers.some((w) => w.alive()),
|
|
362
|
+
async stop() {
|
|
363
|
+
const names = workers.map((w) => w.name);
|
|
364
|
+
for (const name of names)
|
|
365
|
+
running.get(name)?.stdin?.end();
|
|
366
|
+
for (let i = 0; names.some((name) => running.has(name)) && i < 40; i++)
|
|
367
|
+
await sleep(250);
|
|
368
|
+
const stuck = names.filter((name) => running.has(name));
|
|
369
|
+
if (stuck.length)
|
|
370
|
+
spawnSync("docker", ["rm", "-f", ...stuck], { stdio: "ignore" });
|
|
371
|
+
},
|
|
372
|
+
};
|
|
328
373
|
}
|
|
329
374
|
/** Ends the containers once the scan is over: a worker exits when its stdin closes, and docker removes its container */
|
|
330
375
|
export async function stopWorkers() {
|
|
@@ -334,3 +379,56 @@ export async function stopWorkers() {
|
|
|
334
379
|
await sleep(250);
|
|
335
380
|
removeAll(); // stuck ones: their docker run would keep us from exiting
|
|
336
381
|
}
|
|
382
|
+
// Routing the background checks' containers around this computer's VPN (native docker on Linux). Docker sends its containers'
|
|
383
|
+
// traffic out by this computer's routes, so while the VPN is connected it goes through the VPN, and a container would test
|
|
384
|
+
// from the VPN's server instead of your network. So they go on a docker network of their own, and a routing rule sends what
|
|
385
|
+
// comes from it out the way this computer goes without the VPN: a table with main's default routes (Windscribe's VPN routes
|
|
386
|
+
// are 0.0.0.0/1 and 128.0.0.0/1 in main, or its own table for WireGuard, never main's default). Windscribe's firewall filters
|
|
387
|
+
// this computer's own traffic, not what it forwards, so it doesn't block them. Only traffic from that network is affected.
|
|
388
|
+
// A container on the host's network sets it up: it takes no sudo, since whoever runs docker could do the same
|
|
389
|
+
export const DIRECT_NETWORK = "windscout-direct";
|
|
390
|
+
const TABLE = 2470; // ponytail: a routing table number nothing else is likely to use; the rules go at 89-90, before Windscribe's
|
|
391
|
+
const ROUTE = `net=$1 t=$2
|
|
392
|
+
while ip rule del from "$net" 2>/dev/null; do :; done
|
|
393
|
+
ip route flush table "$t" 2>/dev/null
|
|
394
|
+
[ "$3" = on ] || exit 0
|
|
395
|
+
ip -4 route show table main default | sed -E 's/ (linkdown|dead|offload|trap|rt_offload|rt_trap|pervasive|notify)//g' |
|
|
396
|
+
while read -r r; do case "$r" in *" via "*) ip route add $r onlink table "$t" ;; *) ip route add $r table "$t" ;; esac; done
|
|
397
|
+
[ -n "$(ip -4 route show table "$t")" ] || { echo "this computer has no default route to send them out by" >&2; exit 1; }
|
|
398
|
+
ip rule add from "$net" to "$net" lookup main priority 89
|
|
399
|
+
ip rule add from "$net" lookup "$t" priority 90`;
|
|
400
|
+
let routed = ""; // the network routed around the VPN, until unroute()
|
|
401
|
+
let routing = Promise.resolve(); // one change at a time: a rule taken away as it's put in could stay
|
|
402
|
+
const oneAtATime = (change) => {
|
|
403
|
+
const p = routing.then(change);
|
|
404
|
+
routing = p.catch(() => { });
|
|
405
|
+
return p;
|
|
406
|
+
};
|
|
407
|
+
const route = (subnet, on) => ["run", "--rm", "--network", "host", "--cap-add", "NET_ADMIN", "--entrypoint", "sh", IMAGE, "-c", ROUTE, "route", subnet, String(TABLE), on ? "on" : "off"];
|
|
408
|
+
/** windscout's own docker network for the background checks, created the first time; resolves to its subnet */
|
|
409
|
+
export async function directNetwork() {
|
|
410
|
+
const subnet = async () => (await docker(["network", "inspect", "-f", "{{range .IPAM.Config}}{{.Subnet}} {{end}}", DIRECT_NETWORK]))
|
|
411
|
+
.out.split(/\s+/).find((s) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s)) ?? "";
|
|
412
|
+
if (!(await subnet()))
|
|
413
|
+
await docker(["network", "create", DIRECT_NETWORK]);
|
|
414
|
+
const s = await subnet();
|
|
415
|
+
if (!s)
|
|
416
|
+
throw new Error("couldn't create windscout's docker network");
|
|
417
|
+
return s;
|
|
418
|
+
}
|
|
419
|
+
/** Sends the traffic from the subnet out the way this computer goes without the VPN; again before each check, since the
|
|
420
|
+
* network may have changed (another Wi-Fi, another gateway) */
|
|
421
|
+
export const routeAround = (subnet) => oneAtATime(async () => {
|
|
422
|
+
routed = subnet; // before it's in: a crash meanwhile takes it away
|
|
423
|
+
const r = await docker(route(subnet, true), { timeout: 60_000 });
|
|
424
|
+
if (r.code)
|
|
425
|
+
throw new Error(`couldn't route the containers around your VPN: ${lastLine(r.out) || `exit ${r.code}`}`);
|
|
426
|
+
});
|
|
427
|
+
/** Takes routeAround()'s rules and table away */
|
|
428
|
+
export const unroute = () => oneAtATime(async () => {
|
|
429
|
+
if (routed)
|
|
430
|
+
await docker(route(routed, false), { timeout: 60_000 });
|
|
431
|
+
routed = "";
|
|
432
|
+
});
|
|
433
|
+
// a crash or a force quit: take the rule away on the way out (it only touches that network's traffic, so it's harmless if not)
|
|
434
|
+
process.on("exit", () => routed && spawnSync("docker", route(routed, false), { stdio: "ignore", timeout: 30_000 }));
|
package/dist/measure.js
CHANGED
|
@@ -10,6 +10,18 @@ setDefaultResultOrder("ipv4first");
|
|
|
10
10
|
const LATENCY_URL = "https://www.gstatic.com/generate_204";
|
|
11
11
|
const SPEED_URL = "https://speed.cloudflare.com/__down?bytes=10000000";
|
|
12
12
|
export const SPEED_SECONDS = 15;
|
|
13
|
+
export const SPEED_MB = 10; // the most a speed test downloads
|
|
14
|
+
const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace"; // the speed test's Cloudflare: it says which IP asked
|
|
15
|
+
/** The IP this connection goes out from, as Cloudflare sees it; "" when it didn't answer */
|
|
16
|
+
export async function outsideIp() {
|
|
17
|
+
try {
|
|
18
|
+
const text = await (await fetch(TRACE_URL, { signal: AbortSignal.timeout(5000) })).text();
|
|
19
|
+
return text.match(/^ip=(\S+)/m)?.[1] ?? "";
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
13
25
|
/** Best round trip of a few tries, in ms; null if none got through */
|
|
14
26
|
export async function latencyMs(tries = 3) {
|
|
15
27
|
let best = null;
|
package/dist/probe.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// One attempt: connect to a location over a protocol, measure through the tunnel, disconnect.
|
|
2
2
|
// windscout connect makes the same attempt and, when it works, stays connected.
|
|
3
3
|
import { latencyMs, SPEED_SECONDS, speedMbps } from "./measure.js";
|
|
4
|
-
import { now, sleep } from "./time.js";
|
|
5
|
-
import { connectState, windscribe } from "./windscribe.js";
|
|
4
|
+
import { duration, now, sleep } from "./time.js";
|
|
5
|
+
import { connectState, place, windscribe } from "./windscribe.js";
|
|
6
6
|
// Stop at the first ends the scan at one connection, so that one has to last: a tunnel can connect and then be cut or throttled
|
|
7
|
-
// once traffic flows
|
|
7
|
+
// once traffic flows, or refuse the next connection, which is the one the user makes. So it counts once it downloads at
|
|
8
|
+
// MIN_MBPS or more, holds up (holdsUp), downloads at MIN_MBPS or more again, and connects again as the user will
|
|
8
9
|
export const MIN_MBPS = 1;
|
|
9
|
-
const
|
|
10
|
-
// shortcut: guesses ~5 s to connect
|
|
11
|
-
export const
|
|
10
|
+
const STEADY_GAP = 2; // seconds between the hold's round trips
|
|
11
|
+
// shortcut: guesses ~5 s to connect, the whole speed tests and ~10 s to reconnect; refine from real runs
|
|
12
|
+
export const steadySeconds = (hold) => 5 + SPEED_SECONDS + hold + SPEED_SECONDS + 10;
|
|
12
13
|
/** Whether stop at the first can stop at this result: it worked, held up, and is fast enough for real use */
|
|
13
14
|
export const reliable = (r) => r.status === "ok" && (r.speedMbps ?? 0) >= MIN_MBPS;
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
/** Whether a hold's round trips so far (true: got through) still hold up: never 2 lost in a row (an outage), and no more
|
|
16
|
+
* than 1 in 10 lost (too lossy to use); fewer than 10 may lose 1 */
|
|
17
|
+
export const holding = (trips) => !trips.some((ok, i) => !ok && trips[i - 1] === false) && trips.filter((ok) => !ok).length * 10 <= Math.max(10, trips.length);
|
|
18
|
+
// nickname pins the exact datacenter: what the user connects with, and what the reconnect checks
|
|
19
|
+
export const target = (r) => r.location.split(" - ").at(-1) || r.query;
|
|
16
20
|
export async function probe(query, protocol, options) {
|
|
17
21
|
try {
|
|
18
22
|
return await attempt(query, protocol, options);
|
|
@@ -31,7 +35,7 @@ export async function disconnect() {
|
|
|
31
35
|
await sleep(1000);
|
|
32
36
|
}
|
|
33
37
|
/** Connects and measures through the tunnel, as probe does, but leaves the connection as it is: up when it worked */
|
|
34
|
-
export async function attempt(query, protocol, { timeout, speedTest, steady =
|
|
38
|
+
export async function attempt(query, protocol, { timeout, speedTest, steady = 0, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
|
|
35
39
|
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
|
|
36
40
|
const end = (status) => ({ ...r, status });
|
|
37
41
|
onStep("connecting");
|
|
@@ -56,42 +60,64 @@ export async function attempt(query, protocol, { timeout, speedTest, steady = fa
|
|
|
56
60
|
// Windscribe refused this app another WireGuard key for the account: says nothing about this network
|
|
57
61
|
else if (s.includes("WireGuard key limit") && now() - t > 3)
|
|
58
62
|
return end("key limit");
|
|
59
|
-
|
|
63
|
+
// an error the app gave up with ("Error: WireGuard adapter setup failed", out of data, …): no need to wait out the timeout
|
|
64
|
+
else if ((s === "Disconnected" || s.startsWith("Error")) && now() - t > 3)
|
|
60
65
|
return end("failed");
|
|
61
66
|
onStep(`connecting ${r.location}`.trim());
|
|
62
67
|
await sleep(1000);
|
|
63
68
|
}
|
|
64
69
|
// a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
|
|
65
|
-
const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol:
|
|
70
|
+
const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol:[ \t]*\S*:(\d+)\s*$/m)?.[1];
|
|
66
71
|
if (port && shown && shown !== port)
|
|
67
72
|
return end("no such port");
|
|
68
|
-
let
|
|
73
|
+
let status = "interrupted", reconnect = false; // reconnect: it passed the other checks, so the reconnect is next
|
|
69
74
|
await measure(async () => {
|
|
70
75
|
// a stopping scan waits for no more measurements: a parallel scan's containers measure one at a time, so they'd queue up
|
|
71
76
|
if (stopped())
|
|
72
77
|
return;
|
|
73
|
-
measured = true;
|
|
74
78
|
onStep(`measuring latency · ${r.location}`);
|
|
75
79
|
r.latencyMs = await latencyMs();
|
|
76
80
|
if (r.latencyMs === null)
|
|
77
|
-
return;
|
|
81
|
+
return void (status = "no internet");
|
|
78
82
|
if (speedTest || steady) {
|
|
79
83
|
onStep(`measuring speed · ${r.location}`);
|
|
80
84
|
r.speedMbps = await speedMbps();
|
|
81
85
|
}
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
status = "ok";
|
|
87
|
+
const first = r.speedMbps ?? 0;
|
|
88
|
+
if (!steady || first < MIN_MBPS)
|
|
89
|
+
return; // the scan never stops at a slower one, so it goes without the checks
|
|
90
|
+
const held = await holdsUp(steady, r.location, stopped, onStep);
|
|
91
|
+
if (held !== true)
|
|
92
|
+
return void (status = held === "interrupted" ? held : "unreliable");
|
|
93
|
+
onStep(`measuring speed again · ${r.location}`);
|
|
94
|
+
r.speedMbps = Math.min(first, (await speedMbps()) ?? 0); // throttled once traffic flowed: the slower one counts
|
|
95
|
+
if (r.speedMbps < MIN_MBPS)
|
|
96
|
+
return void (status = "unreliable");
|
|
97
|
+
reconnect = true;
|
|
84
98
|
});
|
|
85
|
-
if (!
|
|
86
|
-
return end(
|
|
87
|
-
|
|
99
|
+
if (!reconnect)
|
|
100
|
+
return end(status);
|
|
101
|
+
// after its turn to measure: the next container can start while this one connects again, as the user will
|
|
102
|
+
onStep(`reconnecting · ${r.location}`);
|
|
103
|
+
await disconnect();
|
|
104
|
+
const { status: again } = await attempt(target(r), protocol, { timeout, speedTest: false, stopped });
|
|
105
|
+
return end(again === "ok" || again === "interrupted" ? again : "unreliable");
|
|
88
106
|
}
|
|
89
|
-
/** Whether the connection
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
/** Whether the connection holds up for seconds: connected to the same server the whole time, with a round trip through it
|
|
108
|
+
* every STEADY_GAP seconds, as long as they're holding(); "interrupted" when the scan stops first */
|
|
109
|
+
async function holdsUp(seconds, location, stopped, onStep) {
|
|
110
|
+
const trips = [];
|
|
111
|
+
for (const t = now(); now() - t < seconds;) {
|
|
112
|
+
if (stopped())
|
|
113
|
+
return "interrupted";
|
|
114
|
+
onStep(`checking it holds up ${duration(now() - t)}/${duration(seconds)} · ${location}`);
|
|
93
115
|
await sleep(STEADY_GAP * 1000);
|
|
94
|
-
|
|
116
|
+
const s = await connectState(); // dropped, or the app moved it to another server
|
|
117
|
+
if (!s.startsWith("Connected") || (location && s.includes(":") && place(s) !== location))
|
|
118
|
+
return false;
|
|
119
|
+
trips.push((await latencyMs(1)) !== null);
|
|
120
|
+
if (!holding(trips))
|
|
95
121
|
return false;
|
|
96
122
|
}
|
|
97
123
|
return true;
|
package/dist/scan.js
CHANGED
|
@@ -29,7 +29,6 @@ export class Scan {
|
|
|
29
29
|
protocols;
|
|
30
30
|
options;
|
|
31
31
|
probers;
|
|
32
|
-
after;
|
|
33
32
|
results = [];
|
|
34
33
|
finished = []; // countries with every attempt done: the views refresh their tables per country
|
|
35
34
|
errors = []; // why slots stopped early
|
|
@@ -46,16 +45,13 @@ export class Scan {
|
|
|
46
45
|
idle = 0; // seconds spent paused
|
|
47
46
|
resume = () => { };
|
|
48
47
|
unpaused = Promise.resolve();
|
|
49
|
-
constructor(countries, protocols, options, probers = [probe]
|
|
48
|
+
constructor(countries, protocols, options, probers = [probe]) {
|
|
50
49
|
this.countries = countries;
|
|
51
50
|
this.protocols = protocols;
|
|
52
51
|
this.options = options;
|
|
53
52
|
this.probers = probers;
|
|
54
|
-
this.after = after;
|
|
55
53
|
this.total = this.attempts().length;
|
|
56
54
|
this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
|
|
57
|
-
if (after)
|
|
58
|
-
this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
|
|
59
55
|
}
|
|
60
56
|
/** Seconds spent scanning, pauses not counted */
|
|
61
57
|
elapsed() {
|
|
@@ -87,16 +83,13 @@ export class Scan {
|
|
|
87
83
|
const left = new Map(); // attempts to go, per country
|
|
88
84
|
for (const { country } of queue)
|
|
89
85
|
left.set(country, (left.get(country) ?? 0) + 1);
|
|
90
|
-
|
|
91
|
-
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i],
|
|
92
|
-
// then the rest, also what slots whose container stopped left over
|
|
93
|
-
if (this.after)
|
|
94
|
-
await this.work(this.after.prober, this.slots[this.probers.length], () => true, queue, left, hooks);
|
|
86
|
+
// when every slot's container stops, what they left isn't tried: scan.errors says why
|
|
87
|
+
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], queue, left, hooks)));
|
|
95
88
|
return this.results;
|
|
96
89
|
}
|
|
97
|
-
/** One slot: takes the attempts
|
|
98
|
-
async work(prober, slot,
|
|
99
|
-
const { timeout, speedTest, stopAtFirst =
|
|
90
|
+
/** One slot: takes the attempts from the queue, one at a time, until there are none */
|
|
91
|
+
async work(prober, slot, queue, left, hooks) {
|
|
92
|
+
const { timeout, speedTest, stopAtFirst = 0 } = this.options;
|
|
100
93
|
for (;;) {
|
|
101
94
|
while (this.paused && !this.stopping) {
|
|
102
95
|
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
|
@@ -105,10 +98,9 @@ export class Scan {
|
|
|
105
98
|
}
|
|
106
99
|
await this.unpaused;
|
|
107
100
|
}
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
101
|
+
const next = this.stopping ? undefined : queue.shift();
|
|
102
|
+
if (!next)
|
|
110
103
|
return;
|
|
111
|
-
const [next] = queue.splice(i, 1);
|
|
112
104
|
const { country, protocol } = next;
|
|
113
105
|
Object.assign(slot, { current: next, step: "", attemptStart: now() });
|
|
114
106
|
hooks.attempt?.(country, protocol);
|
package/dist/time.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
3
|
/** Seconds on a monotonic clock */
|
|
4
4
|
export const now = () => performance.now() / 1000;
|
|
5
|
+
/** "just now", "5m ago", "3h ago", "2 days ago", for a time in ms since the epoch */
|
|
6
|
+
export function ago(ms) {
|
|
7
|
+
const m = Math.max(0, Math.round((Date.now() - ms) / 60000)), h = Math.round(m / 60), d = Math.round(h / 24);
|
|
8
|
+
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : h < 48 ? `${h}h ago` : `${d} days ago`;
|
|
9
|
+
}
|
|
5
10
|
/** "9s", "1m 05s", "1h 01m" */
|
|
6
11
|
export function duration(sec) {
|
|
7
12
|
sec = Math.max(0, Math.round(sec));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Connecting to the working connections a scan found, without a terminal (windscout connect in a script): one line each.
|
|
2
|
+
// In a terminal, the results screen (results.ts) connects and keeps you connected instead.
|
|
3
|
+
import { attempt, disconnect, target } from "../probe.js";
|
|
4
|
+
import { duration, now } from "../time.js";
|
|
5
|
+
import { label } from "../watch.js";
|
|
6
|
+
import { command } from "./report.js";
|
|
7
|
+
import { cyan, green, yellow } from "./text.js";
|
|
8
|
+
/** Runs a result's connect command and checks it as the scan does: connected, on its port, with internet through it.
|
|
9
|
+
* True once it is; otherwise disconnects (and waits until the app has), so the next connect isn't swallowed */
|
|
10
|
+
async function connectTo(r, timeout, stopped) {
|
|
11
|
+
console.log(cyan(command(r)));
|
|
12
|
+
const t = now(), { status, location } = await attempt(target(r), r.protocol, { timeout, speedTest: false, stopped });
|
|
13
|
+
if (status === "ok")
|
|
14
|
+
return console.log(green(`Connected: ${location}`)), true;
|
|
15
|
+
console.log(yellow(`${status} after ${duration(now() - t)}`));
|
|
16
|
+
await disconnect();
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
/** Connects to list[from], or when it doesn't connect, to the next ones in turn, around to the one before it.
|
|
20
|
+
* The index of the one that connected; -1 when none did */
|
|
21
|
+
export async function connectFrom(list, from, timeout, stopped = () => false) {
|
|
22
|
+
for (let k = 0; k < list.length && !stopped(); k++) {
|
|
23
|
+
const i = (from + k) % list.length;
|
|
24
|
+
console.log(`\n${k + 1}/${list.length} ${label(list[i])}`);
|
|
25
|
+
if (await connectTo(list[i], timeout, stopped))
|
|
26
|
+
return i;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
}
|