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/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 it runs on this computer.
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 installed */
71
- function exec(cmd, args, { input, onLine = () => { }, timeout } = {}) {
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 runs on this computer after them: IKEv2 (Windscribe's Linux app
111
- * has none), and WireGuard when their kernel can't run its routing (see DOCKERFILE) */
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
- export async function buildImage(onLine) {
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
- export async function startWorkers(n, session, onStatus) {
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
- const name = `windscout-${process.pid}-${i + 1}`;
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, "--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
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 = false, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
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 probers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((p) => p !== null);
327
- return { probers, wireguard };
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. It counts once it downloads at MIN_MBPS or more, then stays up with internet for STEADY_CHECKS round trips
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 STEADY_CHECKS = 5, STEADY_GAP = 2; // seconds between the round trips
10
- // shortcut: guesses ~5 s to connect and the whole speed test; refine from real runs
11
- export const STEADY_SECONDS = 5 + SPEED_SECONDS + STEADY_CHECKS * (STEADY_GAP + 0.5);
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
- // "Connected: Miami - Vice" → "Miami - Vice"
15
- const place = (state) => state.split(":").slice(1).join(":").trim();
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 = false, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
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
- else if (s === "Disconnected" && now() - t > 3)
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: .*:(\d+)\s*$/m)?.[1];
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 measured = false, holds = true;
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
- if (steady)
83
- holds = await holdsUp(r.location, onStep);
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 (!measured)
86
- return end("interrupted");
87
- return end(r.latencyMs === null ? "no internet" : holds ? "ok" : "unreliable");
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 stays up with internet through it: STEADY_CHECKS round trips, STEADY_GAP seconds apart, each with a retry */
90
- async function holdsUp(location, onStep) {
91
- for (let i = 1; i <= STEADY_CHECKS; i++) {
92
- onStep(`checking it holds up ${i}/${STEADY_CHECKS} · ${location}`);
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
- if (!(await connectState()).startsWith("Connected") || (await latencyMs(2)) === null)
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], after) {
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
- const later = this.after?.protocols ?? new Set();
91
- await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
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 it can make from the queue, one at a time, until there are none */
98
- async work(prober, slot, can, queue, left, hooks) {
99
- const { timeout, speedTest, stopAtFirst = false } = this.options;
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 i = this.stopping ? -1 : queue.findIndex((a) => can(a.protocol));
109
- if (i < 0)
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
+ }