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/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 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,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
- 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
@@ -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 = false } = this.options;
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
+ }
@@ -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 { STEADY_SECONDS } from "../probe.js";
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
- let seconds = String(timeout), editing = false; // the timeout, as typed
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(seconds);
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 - 24 - version.length);
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
- const short = timeout < MIN_TIMEOUT;
97
- const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
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 + STEADY_SECONDS)} if ${lead.country} ${lead.protocol} still holds up`
117
- : "after a speed and stability check")}`;
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: this is when none holds up
160
- ? `up to ${bold(String(n))} attempts · at most ${bold("≈ " + duration(total))} ${costs}`
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 to wait for each connection · enter/esc done"
164
- : searching
165
- ? "type to filter · ↑↓ move · enter/esc done"
166
- : `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
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 = false;
286
+ searching = true, editing = null;
279
287
  else if (y === hit.timeoutRow)
280
- editing = true, searching = false;
288
+ editing = "timeout", searching = false;
289
+ else if (y === hit.holdRow)
290
+ editing = "hold", searching = false;
281
291
  else {
282
- searching = editing = false; // clicking anywhere else ends typing, like leaving a text field
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 = false;
360
+ editing = null;
350
361
  else if (k === "backspace")
351
- seconds = seconds.slice(0, -1);
352
- else if (/^[0-9]$/.test(str ?? "") && seconds.length < 4)
353
- seconds = seconds === "0" ? str : seconds + str;
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 = true, seconds = "";
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")