windscout 0.7.1 → 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 +85 -12
- package/dist/background.js +131 -0
- package/dist/cli.js +155 -112
- package/dist/config.js +25 -4
- package/dist/docker.js +120 -22
- package/dist/measure.js +12 -0
- package/dist/probe.js +50 -24
- package/dist/scan.js +1 -1
- package/dist/time.js +5 -0
- package/dist/tui/connect.js +29 -0
- package/dist/tui/picker.js +46 -33
- 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
|
@@ -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,18 +93,20 @@ 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;
|
|
@@ -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
|
@@ -89,7 +89,7 @@ export class Scan {
|
|
|
89
89
|
}
|
|
90
90
|
/** One slot: takes the attempts from the queue, one at a time, until there are none */
|
|
91
91
|
async work(prober, slot, queue, left, hooks) {
|
|
92
|
-
const { timeout, speedTest, stopAtFirst =
|
|
92
|
+
const { timeout, speedTest, stopAtFirst = 0 } = this.options;
|
|
93
93
|
for (;;) {
|
|
94
94
|
while (this.paused && !this.stopping) {
|
|
95
95
|
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
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
|
+
}
|
package/dist/tui/picker.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
|
|
1
|
+
import { loadDockerWireGuard, MIN_HOLD, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
|
|
2
2
|
import { dockerState, loginSeconds, notInContainers, workerCount } from "../docker.js";
|
|
3
|
-
import {
|
|
3
|
+
import { steadySeconds } from "../probe.js";
|
|
4
4
|
import { attemptSeconds, estimate, ORDERS, plan } from "../scan.js";
|
|
5
|
-
import { duration } from "../time.js";
|
|
5
|
+
import { ago, duration } from "../time.js";
|
|
6
6
|
import { canUpdate, checkUpdate, versionLines } from "../update.js";
|
|
7
7
|
import { COUNTRIES, protocolPorts, PROTOCOLS, withPorts } from "../windscribe.js";
|
|
8
8
|
import { countryName, flag } from "./country.js";
|
|
@@ -20,17 +20,13 @@ const needsLast = (o) => o === "last-first" || o === "last-only";
|
|
|
20
20
|
// only the last results: the countries, protocols and ports are greyed out, and their keys and clicks say why
|
|
21
21
|
const SKIPPED = "skipped with only the last results: o changes the scanning type";
|
|
22
22
|
const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
|
|
23
|
-
// "5m ago", "3h ago", "2 days ago"
|
|
24
|
-
const ago = (ms) => {
|
|
25
|
-
const m = Math.max(0, Math.round((Date.now() - ms) / 60000)), h = Math.round(m / 60), d = Math.round(h / 24);
|
|
26
|
-
return m < 1 ? "just now" : m < 60 ? `${m}m ago` : h < 48 ? `${h}h ago` : `${d} days ago`;
|
|
27
|
-
};
|
|
28
23
|
/** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
|
|
29
|
-
export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, allPorts, docker, last }) {
|
|
24
|
+
export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst, hold, allPorts, docker, last }) {
|
|
30
25
|
const selected = new Set(saved?.countries ?? []);
|
|
31
26
|
const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
|
|
32
27
|
let cursor = 0, top = 0, message = "", query = "", searching = false;
|
|
33
|
-
|
|
28
|
+
const typed = { timeout: String(timeout), hold: String(hold) }; // the settings in seconds, as typed
|
|
29
|
+
let editing = null; // the one being typed
|
|
34
30
|
let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
|
|
35
31
|
let portLists = null; // each protocol's ports, once the app said
|
|
36
32
|
let starting = -1; // once start is pressed: the start button's spinner frame, until the next screen shows
|
|
@@ -59,11 +55,12 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
59
55
|
const version = versionLines(true);
|
|
60
56
|
const off = order === "last-only"; // only the saved connections: the countries, protocols and ports are skipped
|
|
61
57
|
const missing = needsLast(order) && !last; // a type by the last result, and no saved scan: start is off
|
|
62
|
-
timeout = Number(
|
|
58
|
+
timeout = Number(typed.timeout);
|
|
59
|
+
hold = Number(typed.hold);
|
|
63
60
|
const { list, skipped } = todo(), n = list.length;
|
|
64
61
|
// only the last results lists the countries it scans, all selected; the user's own selection is back with the other types
|
|
65
62
|
const matches = off ? [...new Set(list.map((a) => a.country))] : visible();
|
|
66
|
-
const height = Math.max(3, rows -
|
|
63
|
+
const height = Math.max(3, rows - 25 - version.length);
|
|
67
64
|
if (!off) { // so the selection's list keeps its cursor and scroll for then
|
|
68
65
|
cursor = Math.max(0, Math.min(cursor, matches.length - 1));
|
|
69
66
|
if (cursor < top)
|
|
@@ -93,8 +90,9 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
93
90
|
const fwLine = `${dim(under("f"))} ${firewall ? green("● " + fwLabel) : dim("○ " + fwLabel)} ${dim(docker
|
|
94
91
|
? "not used with docker"
|
|
95
92
|
: firewall ? "only VPN traffic gets through" : "off: apps may leak in between")}`;
|
|
96
|
-
|
|
97
|
-
const
|
|
93
|
+
// a setting in seconds: as typed, with the cursor while typing, yellow when it's under its least
|
|
94
|
+
const field = (k, least) => editing === k ? bold(typed[k]) + cyan("▏") : (Number(typed[k]) < least ? yellow : bold)(typed[k] || "?");
|
|
95
|
+
const timeoutLine = `${dim(under("t"))} timeout ${field("timeout", MIN_TIMEOUT)} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
|
|
98
96
|
const dockerLabel = "parallel scan in docker containers";
|
|
99
97
|
const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
|
|
100
98
|
? "off: one attempt at a time"
|
|
@@ -113,8 +111,11 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
113
111
|
const lead = list[0]?.saved ? list[0] : null;
|
|
114
112
|
const stopLine = `${dim(under("w"))} ${stopAtFirst ? green("● " + stopLabel) : dim("○ " + stopLabel)} ${dim(!stopAtFirst
|
|
115
113
|
? "off: tries every attempt"
|
|
116
|
-
: lead ? `≈ ${duration(login +
|
|
117
|
-
:
|
|
114
|
+
: lead ? `≈ ${duration(login + steadySeconds(hold))} if ${lead.country} ${lead.protocol} still holds up`
|
|
115
|
+
: `≈ ${duration(steadySeconds(hold))} of checks per working one`)}`;
|
|
116
|
+
const holdLine = `${dim(under("h"))} hold ${field("hold", MIN_HOLD)} s ${dim(stopAtFirst
|
|
117
|
+
? `each working one stays connected this long · at least ${MIN_HOLD}`
|
|
118
|
+
: "not used: stop at the first is off")}`;
|
|
118
119
|
const search = off
|
|
119
120
|
? yellow("the last results' countries: only these are scanned")
|
|
120
121
|
: searching ? `${cyan("🔍")} ${bold(query)}${cyan("▏")}`
|
|
@@ -136,7 +137,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
136
137
|
["quit", under("Q") + "uit"],
|
|
137
138
|
];
|
|
138
139
|
const buttonLine = buttons.map(([id, label]) => (id === "start" && n && !missing ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
|
|
139
|
-
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine];
|
|
140
|
+
const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, timeoutLine, dockerLine, orderLine, stopLine, holdLine];
|
|
140
141
|
const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
|
|
141
142
|
const width = vw(table[0]);
|
|
142
143
|
const head = box(settings, {
|
|
@@ -156,14 +157,16 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
156
157
|
? yellow("no saved scan yet, so start is off: press o for another scanning type")
|
|
157
158
|
: !n ? yellow(skipped.length ? `the docker containers can't run ${skipped.join(" or ")}: d turns docker off`
|
|
158
159
|
: `select at least one country and one protocol${last ? ", or o for only the last results" : ""}`)
|
|
159
|
-
: stopAtFirst // it can end at any attempt
|
|
160
|
-
? `up to ${bold(String(n))} attempts ·
|
|
160
|
+
: stopAtFirst // it can end at any attempt, and each working one adds its checks (the w line)
|
|
161
|
+
? `up to ${bold(String(n))} attempts · ${bold("≈ " + duration(total))} if none connects ${costs}`
|
|
161
162
|
: `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(total))} ${costs}`, width),
|
|
162
|
-
centerIn(dim(editing
|
|
163
|
-
? "type the seconds
|
|
164
|
-
:
|
|
165
|
-
? "type to
|
|
166
|
-
:
|
|
163
|
+
centerIn(dim(editing === "hold"
|
|
164
|
+
? "type the seconds each working connection has to stay connected · enter/esc done"
|
|
165
|
+
: editing
|
|
166
|
+
? "type the seconds to wait for each connection · enter/esc done"
|
|
167
|
+
: searching
|
|
168
|
+
? "type to filter · ↑↓ move · enter/esc done"
|
|
169
|
+
: `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
|
|
167
170
|
centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall · t timeout · d docker · o type · w first`), width),
|
|
168
171
|
];
|
|
169
172
|
if (message)
|
|
@@ -188,7 +191,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
188
191
|
});
|
|
189
192
|
hit = {
|
|
190
193
|
protoRow: row(protoLine), protoHits, portsRow: row(portsLine), speedRow: SPEED_TEST ? row(speedLine) : -1,
|
|
191
|
-
firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine),
|
|
194
|
+
firewallRow: row(fwLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine), orderRow: row(orderLine), stopRow: row(stopLine), holdRow: row(holdLine),
|
|
192
195
|
searchRow: at.top + head.length + 1,
|
|
193
196
|
firstRow: at.top + head.length + 5, shown: shown.length,
|
|
194
197
|
buttonRow: at.top + head.length + table.length + 1, buttonHits,
|
|
@@ -219,6 +222,10 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
219
222
|
message = `the timeout must be at least ${MIN_TIMEOUT} seconds`;
|
|
220
223
|
return false;
|
|
221
224
|
}
|
|
225
|
+
if (stopAtFirst && !(hold >= MIN_HOLD)) {
|
|
226
|
+
message = `the hold must be at least ${MIN_HOLD} seconds`;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
222
229
|
// keep the user's order of preference stable: list order
|
|
223
230
|
const choice = {
|
|
224
231
|
countries: COUNTRIES.filter((c) => selected.has(c)),
|
|
@@ -227,6 +234,7 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
227
234
|
firewall,
|
|
228
235
|
order,
|
|
229
236
|
stopAtFirst,
|
|
237
|
+
hold,
|
|
230
238
|
allPorts,
|
|
231
239
|
timeout,
|
|
232
240
|
docker,
|
|
@@ -275,11 +283,13 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
275
283
|
else if (off && (onCountries || y === hit.protoRow || y === hit.portsRow))
|
|
276
284
|
message = SKIPPED;
|
|
277
285
|
else if (y === hit.searchRow)
|
|
278
|
-
searching = true, editing =
|
|
286
|
+
searching = true, editing = null;
|
|
279
287
|
else if (y === hit.timeoutRow)
|
|
280
|
-
editing =
|
|
288
|
+
editing = "timeout", searching = false;
|
|
289
|
+
else if (y === hit.holdRow)
|
|
290
|
+
editing = "hold", searching = false;
|
|
281
291
|
else {
|
|
282
|
-
searching = editing =
|
|
292
|
+
searching = false, editing = null; // clicking anywhere else ends typing, like leaving a text field
|
|
283
293
|
if (y >= hit.firstRow && y < hit.firstRow + hit.shown) {
|
|
284
294
|
cursor = top + (y - hit.firstRow);
|
|
285
295
|
const c = matches[cursor];
|
|
@@ -345,12 +355,13 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
345
355
|
else if (k === "pagedown")
|
|
346
356
|
cursor = Math.min(matches.length - 1, cursor + 10);
|
|
347
357
|
else if (editing) {
|
|
358
|
+
const text = typed[editing];
|
|
348
359
|
if (k === "return" || k === "escape")
|
|
349
|
-
editing =
|
|
360
|
+
editing = null;
|
|
350
361
|
else if (k === "backspace")
|
|
351
|
-
|
|
352
|
-
else if (/^[0-9]$/.test(str ?? "") &&
|
|
353
|
-
|
|
362
|
+
typed[editing] = text.slice(0, -1);
|
|
363
|
+
else if (/^[0-9]$/.test(str ?? "") && text.length < 4)
|
|
364
|
+
typed[editing] = text === "0" ? str : text + str;
|
|
354
365
|
}
|
|
355
366
|
else if (searching) {
|
|
356
367
|
// typing goes into the search box; letters are not shortcuts here
|
|
@@ -386,7 +397,9 @@ export function pick(saved, { timeout, speedTest, firewall, order, stopAtFirst,
|
|
|
386
397
|
else if (k === "f")
|
|
387
398
|
firewall = !firewall;
|
|
388
399
|
else if (k === "t")
|
|
389
|
-
editing =
|
|
400
|
+
editing = "timeout", typed.timeout = "";
|
|
401
|
+
else if (k === "h")
|
|
402
|
+
editing = "hold", typed.hold = "";
|
|
390
403
|
else if (k === "d")
|
|
391
404
|
return void toggleDocker();
|
|
392
405
|
else if (k === "o")
|