windscout 0.5.7 → 0.6.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 CHANGED
@@ -13,6 +13,7 @@ WindScout drives the official `windscribe-cli`: it connects to each country × p
13
13
  - Linux, macOS or Windows 10/11
14
14
  - Node.js 20+
15
15
  - The [Windscribe desktop app](https://windscribe.com/download) installed, running and logged in. It includes `windscribe-cli`, which WindScout uses to control the app. Check with `windscribe-cli status`, which should show `Logged in`. On Windows the app doesn't put the CLI on `PATH`, so run `"C:\Program Files\Windscribe\windscribe-cli.exe" status` instead. WindScout finds it there on its own.
16
+ - Optional, for [parallel scans](#parallel-scan-with-docker): Docker. WindScout offers to install it.
16
17
 
17
18
  ## Install
18
19
 
@@ -52,6 +53,8 @@ Run `windscout` with no arguments to open the interactive picker:
52
53
  | `1`–`6` | toggle a protocol (`1`–`5` on Linux, which has no IKEv2) |
53
54
  | `s` | toggle the speed test (off = connect-only mode) |
54
55
  | `f` | toggle "block internet while scanning" (firewall) |
56
+ | `r` | toggle random order: scan the selected countries shuffled, a new order each run |
57
+ | `d` | toggle the [parallel scan in docker containers](#parallel-scan-with-docker) (installs or starts docker first, after asking) |
55
58
  | `u` | update to the newer version and restart (only when the picker shows one is out) |
56
59
  | `enter` | start scanning |
57
60
  | `q` | quit (during a scan: stop and show partial results; press again to force quit) |
@@ -60,7 +63,7 @@ Run `windscout` with no arguments to open the interactive picker:
60
63
 
61
64
  Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
62
65
 
63
- While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command; type a number at the prompt to connect to it.
66
+ While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command. At the prompt, press enter to connect to the best one, type another one's number to connect to that, or `q` to quit.
64
67
 
65
68
  ### Non-interactive
66
69
 
@@ -80,6 +83,8 @@ windscout -t 40 -a # longer connect timeout, also list
80
83
  | `-a, --all` | include failed attempts in the final table | off |
81
84
  | `-n, --no-speed` | connect-only mode: skip the download test, rank by latency | picker: saved, or connect-only on first run; with `-c`: speed test on |
82
85
  | `-f, --firewall` | block internet while scanning, except through the VPN | picker: saved, or off; with `-c`: off |
86
+ | `-r, --random` | scan the countries in a random order | picker: saved, or off; with `-c`: off |
87
+ | `-d, --docker` | [scan in parallel, in docker containers](#parallel-scan-with-docker) | picker: saved, or off; with `-c`: off |
83
88
  | `-v, --version` | print the installed version; if a newer one is on npm, show the update command (with `sudo` when needed) and ask (y/n) to run it | |
84
89
 
85
90
  ## Connect-only mode
@@ -107,6 +112,48 @@ While scanning, press `tab` to switch between the availability grid and the spee
107
112
 
108
113
  Between attempts the VPN is disconnected for a moment, and apps on your computer (browser, messengers, updates) can go online over your real connection. Turn on **block internet while scanning** with `f` in the picker or `--firewall`, and WindScout switches on the Windscribe app's firewall for the scan: nothing gets online except through the VPN. When the scan ends or you stop it, the firewall goes back off (if you already had it on, WindScout leaves it on). While a scan is paused, the firewall keeps blocking.
109
114
 
115
+ ## Parallel scan with Docker
116
+
117
+ A normal scan tries one location at a time, because your computer has one VPN connection. Turn on **parallel scan** with `d` in the picker or `--docker`, and each attempt runs in its own docker container instead. Each container has its own Windscribe app (Windscribe's Linux CLI app) and so its own VPN connection, and several locations are tried at once. The results come back to the scan screen, which shows a line per container.
118
+
119
+ **How many containers:** one per attempt, as many as docker's CPUs allow and three quarters of its memory (at 250 MB each), at most 8. The picker shows the number (`8 at a time`) and the estimate that goes with it.
120
+
121
+ **First time:** if docker isn't installed or isn't running, `d` (or a scan with `--docker`) opens a dialog that says what WindScout will do, and does it once you agree:
122
+
123
+ - **macOS:** downloads Docker Desktop from docker.com (about 600 MB), installs it in `/Applications` with its license accepted, and starts it.
124
+ - **Linux:** runs Docker's install script from get.docker.com (or, where docker.com refuses, installs your distribution's own docker package), adds you to the `docker` group and starts docker. The group takes effect in a new login session: log out and back in (or run `newgrp docker`), then start WindScout again. Members of the `docker` group can control docker, which is as good as root.
125
+ - **Windows:** installs Docker Desktop with winget, with its license accepted and its WSL 2 engine, and starts it. Windows asks for permission, and may want a restart. Docker Desktop may also ask to install or update WSL: do what it asks, then try again. If Docker Desktop is set to Windows containers, switch it to Linux containers (right-click its icon by the clock).
126
+
127
+ Steps that need root ask for your password in the dialog. WindScout checks it with `sudo` once, so a typo counts as one failed try. It keeps the password in memory until the steps are done and never saves it. Docker Desktop is free for personal use, education and small businesses; bigger companies need a [paid plan](https://www.docker.com/pricing/).
128
+
129
+ Then the first parallel scan builds a local image, `windscout-worker`, with Node and Windscribe's Linux CLI app (the latest stable release on [GitHub](https://github.com/Windscribe/Desktop-App/releases)). That takes a few minutes. Later scans reuse it until WindScout updates.
130
+
131
+ **In Iran (and other places docker.com refuses):** Docker Hub and docker.com refuse Iranian addresses (US sanctions), so every download falls back to mirrors in Iran when the official source refuses or doesn't answer. Everywhere else, the official sources are used as before:
132
+
133
+ - **The image's base** (`node:22-bookworm-slim`) comes from Docker Hub, else from the first mirror of it that answers: `registry.docker.ir`, `docker.iranserver.com`, `docker.kernel.ir`, `mirror2.chabokan.net`, `docker-mirror.liara.ir`, `hub.hamdocker.ir`, `docker.arvancloud.ir`, `focker.ir`, `docker.mobinhost.com`, `mirror-docker.runflare.com`. It's pinned by digest, so a mirror can only hand over that exact image. To try other mirrors first, list them in `WINDSCOUT_REGISTRY_MIRRORS` (comma-separated hosts).
134
+ - **Debian's packages** in the image come from deb.debian.org, else `mirror.shatel.ir`, `mirror.iranserver.com`, `mirror.mobinhost.com` or `mirror2.chabokan.net`. apt checks Debian's signatures, whichever mirror serves them.
135
+ - **Windscribe's app** in the image comes from GitHub, which Iran can reach.
136
+ - **Docker on Linux:** where get.docker.com refuses, your distribution's own docker package is installed instead, from the mirrors your system already uses.
137
+ - **Docker Desktop on macOS and Windows** has no mirror anywhere: when docker.com refuses the download, the dialog says so. Connect a VPN (Windscribe will do), then press Retry. That's needed once, for the install.
138
+
139
+ The mirrors come from [Mirava](https://github.com/MiravaOrg/Mirava)'s list of mirrors in Iran; they come and go, and that's where to look for new ones.
140
+
141
+ **Kernel check:** each container first checks that docker's Linux kernel gives it what Windscribe needs: a tunnel device (tun) and a firewall (nftables). If not, the scan stops and says so, rather than showing every location as blocked. Windscribe's WireGuard also needs nftables' `fib` in its firewall table, which Docker Desktop's own Linux VM lacks (on macOS, and on Windows without WSL 2). There, WireGuard runs on this computer instead (see below).
142
+
143
+ **Login:** each container's Windscribe app logs in once. WindScout asks for your Windscribe username and password (and a 2FA code or captcha when Windscribe asks for one) and passes them to the containers, one at a time. Each login is kept in that container's docker volume (`windscout-1`, `windscout-2`, …), so later scans don't ask.
144
+
145
+ **Remember username and password** is ticked to start with (tab to it and press space, or click it). Once the login works, WindScout saves both in `~/.config/windscout/login.json`, as plain text that only you can read. Then containers that are new next time (a scan with more attempts) log in by themselves, with no terminal too; a 2FA code is never saved, so with 2FA they still ask. Untick it and nothing is saved, and a login saved before is forgotten. Deleting that file forgets it too. With `-c` and no terminal, only containers that are logged in already, or can log in with the remembered login, take part.
146
+
147
+ **Measuring:** the containers share your internet connection, so they take turns measuring latency and speed while the connecting happens in parallel. Numbers stay comparable with a normal scan. Most of the time saved comes from attempts that hang until the timeout, which overlap now.
148
+
149
+ **Differences from a normal scan:**
150
+
151
+ - IKEv2, and WireGuard when the kernel check finds it can't run in the containers, are tried on this computer once the containers are done, one at a time like a normal scan. The scan screen shows them on a line of their own (`this computer`), and the picker's estimate counts them. Windscribe's Linux app has no IKEv2.
152
+ - This computer's own Windscribe connection is disconnected at the start and stays off while the containers scan, since it would carry their traffic too.
153
+ - No firewall: "block internet while scanning" isn't used, since it would block the containers' traffic too.
154
+
155
+ The containers are removed after the scan. To remove the logins too, run `docker volume rm windscout-1 windscout-2 …`. To remove the image, run `docker rmi windscout-worker`.
156
+
110
157
  ## Output
111
158
 
112
159
  While scanning:
@@ -142,7 +189,7 @@ At the end, the results, a per-protocol summary and the best pick are printed to
142
189
  - **The country list is built in.** On v2.24, `windscribe-cli locations` returns nothing, so WindScout uses a fixed list of country codes. Codes Windscribe doesn't serve are skipped quickly.
143
190
  - **A full scan takes a while.** The worst case is countries × protocols × timeout. Narrow it with `-c` and `-p`.
144
191
  - **No flags in macOS Terminal.** Terminal.app draws flag emoji wider than their space, which pushes table borders out of line, so WindScout leaves them out there (as on Windows). iTerm2 and other terminals show them.
145
- - **Your connection changes during the scan.** WindScout connects and disconnects the VPN repeatedly, so don't run it while you need a stable connection.
192
+ - **Your connection changes during the scan.** WindScout connects and disconnects the VPN repeatedly, so don't run it while you need a stable connection. A [parallel scan](#parallel-scan-with-docker) does that inside its containers instead, and your computer's own VPN stays off.
146
193
 
147
194
  ## Development
148
195
 
@@ -151,6 +198,7 @@ The source is TypeScript in `src/`. It compiles to `dist/`, which is what gets p
151
198
  ```sh
152
199
  npm install
153
200
  npm run build # compile src/ to dist/ (the windscout command is dist/cli.js)
201
+ npm test # build, then check the scan's scheduling (no VPN, no docker)
154
202
  node dist/cli.js # run your build; takes the same options as windscout
155
203
  ./publish.sh # build, install the packed tarball to check it runs, publish to npm (bump the version first)
156
204
  ```
package/dist/cli.js CHANGED
@@ -5,8 +5,11 @@
5
5
  import { createInterface } from "node:readline";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { parseArgs } from "node:util";
8
- import { CONFIG, DEFAULT_TIMEOUT, loadConfig, MIN_TIMEOUT } from "./config.js";
8
+ import { CONFIG, DEFAULT_TIMEOUT, loadConfig, loadDockerWireGuard, MIN_TIMEOUT, saveDockerWireGuard } from "./config.js";
9
+ import { notInContainers, stopWorkers } from "./docker.js";
10
+ import { probe } from "./probe.js";
9
11
  import { Scan } from "./scan.js";
12
+ import { startParallel } from "./tui/docker.js";
10
13
  import { pick } from "./tui/picker.js";
11
14
  import { logProgress, showProgress } from "./tui/progress.js";
12
15
  import { command, report, target } from "./tui/report.js";
@@ -15,6 +18,7 @@ import { cyan, green, yellow } from "./tui/text.js";
15
18
  import { now, sleep } from "./time.js";
16
19
  import { canUpdate, checkUpdate, runUpdate, updateNotice, versionLines } from "./update.js";
17
20
  import { blockInternet, CLI, connectState, isFirewallOn, PROTOCOLS, restoreFirewall, waitForLogin, windscribe } from "./windscribe.js";
21
+ import { worker } from "./worker.js";
18
22
  const HELP = `Usage: windscout [options]
19
23
 
20
24
  Find which Windscribe locations and protocols work from your network, ranked by speed.
@@ -27,6 +31,8 @@ Options:
27
31
  -a, --all also show failed attempts in the final table
28
32
  -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
29
33
  -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
34
+ -r, --random scan the countries in a random order
35
+ -d, --docker scan in parallel, in docker containers (as many as docker's CPUs and memory allow, up to 8)
30
36
  -v, --version show the installed version (and offer to update if a newer one is out)
31
37
  -h, --help show this help
32
38
 
@@ -36,18 +42,18 @@ Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
36
42
  const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
37
43
  const ask = (question) => new Promise((resolve) => {
38
44
  const rl = createInterface({ input: process.stdin, output: out });
39
- rl.on("SIGINT", () => rl.close()); // Ctrl-C / Ctrl-D: same as enter
40
- rl.on("close", () => resolve(""));
45
+ rl.on("SIGINT", () => rl.close()); // Ctrl-C / Ctrl-D: no answer, null
46
+ rl.on("close", () => resolve(null));
41
47
  rl.question(question, (a) => (resolve(a.trim()), rl.close()));
42
48
  });
43
- /** Asks which numbered connection to make, then runs its command and waits for it to connect */
49
+ /** Asks which numbered connection to make (enter: the best), then runs its command and waits for it to connect */
44
50
  async function offerConnect(choices, timeout) {
45
- const others = choices.length > 1 ? `, 1-${choices.length - 1} = other` : "";
51
+ const others = choices.length > 2 ? `, 1-${choices.length - 1} = other` : choices.length > 1 ? ", 1 = other" : "";
46
52
  for (;;) {
47
- const a = await ask(`\nconnect to # (0 = best${others}, enter to quit): `);
48
- if (!a)
53
+ const a = await ask(`\nconnect to # (enter or 0 = best${others}, q = quit): `);
54
+ if (a === null || /^q(uit)?$/i.test(a))
49
55
  return;
50
- const r = /^\d+$/.test(a) ? choices[Number(a)] : undefined;
56
+ const r = a === "" ? choices[0] : /^\d+$/.test(a) ? choices[Number(a)] : undefined;
51
57
  if (!r) {
52
58
  console.log(yellow(`no #${a} in the tables`));
53
59
  continue;
@@ -83,15 +89,22 @@ async function main() {
83
89
  version: { type: "boolean", short: "v", default: false },
84
90
  "no-speed": { type: "boolean", short: "n", default: false },
85
91
  firewall: { type: "boolean", short: "f", default: false },
92
+ random: { type: "boolean", short: "r", default: false },
93
+ docker: { type: "boolean", short: "d", default: false },
94
+ worker: { type: "boolean", default: false }, // in a parallel scan's container
86
95
  },
87
96
  });
97
+ if (a.worker) {
98
+ await worker();
99
+ process.exit(); // its stdin may still be open: it has no login to scan with
100
+ }
88
101
  if (a.help)
89
102
  return console.log(HELP);
90
103
  checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
91
104
  if (a.version) {
92
105
  await checkUpdate();
93
106
  console.log(versionLines().join("\n"));
94
- if (canUpdate() && process.stdin.isTTY && out.isTTY && /^y(es)?$/i.test(await ask("update now? (y/n) ")))
107
+ if (canUpdate() && process.stdin.isTTY && out.isTTY && /^y(es)?$/i.test((await ask("update now? (y/n) ")) ?? ""))
95
108
  process.exitCode = runUpdate();
96
109
  return;
97
110
  }
@@ -104,6 +117,8 @@ async function main() {
104
117
  let protocols = a.protocols ? list(a.protocols) : undefined;
105
118
  let speedTest = !a["no-speed"];
106
119
  let firewall = a.firewall;
120
+ let random = a.random;
121
+ let docker = a.docker;
107
122
  if (!countries) {
108
123
  if (!tty)
109
124
  throw new Error("no terminal for the picker: pass countries with -c");
@@ -115,6 +130,8 @@ async function main() {
115
130
  speedTest: speedTest && (saved?.speed ?? false), // first run: connect-only
116
131
  // already on in the app: show it on, since the scan won't turn it off
117
132
  firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
133
+ random: random || (saved?.random ?? false),
134
+ docker: docker || (saved?.docker ?? false),
118
135
  });
119
136
  if (!choice)
120
137
  return;
@@ -124,16 +141,37 @@ async function main() {
124
141
  protocols ??= choice.protocols;
125
142
  speedTest = choice.speed;
126
143
  firewall = choice.firewall;
144
+ random = choice.random;
127
145
  timeout = choice.timeout;
146
+ docker = choice.docker;
128
147
  }
129
148
  protocols ??= PROTOCOLS;
130
149
  timeout ??= DEFAULT_TIMEOUT;
131
- const scan = new Scan(countries, protocols, { timeout, speedTest });
132
- const blocking = firewall && (await blockInternet());
150
+ if (random)
151
+ countries = countries.map((c) => [Math.random(), c]).sort(([x], [y]) => x - y).map(([, c]) => c);
152
+ let probers = [probe], after; // after: what runs here once the containers are done
153
+ const later = notInContainers(protocols, loadDockerWireGuard()); // as far as the last parallel scan knows
154
+ const inContainers = countries.length * protocols.filter((p) => !later.has(p)).length;
155
+ if (docker && inContainers) {
156
+ const started = await startParallel(inContainers);
157
+ if (!started)
158
+ return leaveScreen();
159
+ saveDockerWireGuard(started.wireguard);
160
+ probers = started.probers;
161
+ after = { prober: probe, protocols: notInContainers(protocols, started.wireguard), name: "this computer" };
162
+ await windscribe("disconnect"); // a VPN connection here would carry the containers' traffic too
163
+ }
164
+ const scan = new Scan(countries, protocols, { timeout, speedTest }, probers, after);
165
+ // not with containers: their traffic goes out through this computer, and the firewall would block it
166
+ const blocking = firewall && !after && (await blockInternet());
133
167
  const view = tty ? showProgress(scan, blocking) : logProgress(scan);
134
168
  await scan.run(view);
169
+ if (after)
170
+ await stopWorkers();
135
171
  await restoreFirewall();
136
172
  view.close();
173
+ for (const e of new Set(scan.errors))
174
+ console.error(yellow(e));
137
175
  if (scan.stopping)
138
176
  console.log(yellow("interrupted, showing partial results"));
139
177
  const choices = report(scan.results, protocols, { all: a.all, speedTest });
package/dist/config.js CHANGED
@@ -1,11 +1,14 @@
1
- // The picker's selection, remembered between runs.
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ // The picker's selection, and the docker containers' Windscribe login if wanted, remembered between runs.
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { COUNTRIES, PROTOCOLS } from "./windscribe.js";
6
6
  export const DEFAULT_TIMEOUT = 15;
7
7
  export const MIN_TIMEOUT = 10; // seconds; shorter cuts off connections that would have worked
8
8
  export const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
9
+ // ponytail: plain text that only this user can read, like git's credential store; the OS keychain if that's not enough
10
+ export const LOGIN_FILE = join(dirname(CONFIG), "login.json");
11
+ const DOCKER_FILE = join(dirname(CONFIG), "docker.json"); // what the last parallel scan found its containers can do
9
12
  /** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
10
13
  export function loadConfig() {
11
14
  try {
@@ -15,7 +18,9 @@ export function loadConfig() {
15
18
  protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
16
19
  speed: c.speed !== false,
17
20
  firewall: c.firewall === true,
21
+ random: c.random === true,
18
22
  timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
23
+ docker: c.docker === true,
19
24
  };
20
25
  }
21
26
  catch {
@@ -29,3 +34,40 @@ export function saveConfig(c) {
29
34
  }
30
35
  catch { } // not being able to remember isn't worth failing the scan
31
36
  }
37
+ /** Whether WireGuard worked in the docker containers last time (yes until a scan finds otherwise), for the picker's estimate */
38
+ export function loadDockerWireGuard() {
39
+ try {
40
+ return JSON.parse(readFileSync(DOCKER_FILE, "utf8")).wireguard !== false;
41
+ }
42
+ catch {
43
+ return true;
44
+ }
45
+ }
46
+ export function saveDockerWireGuard(wireguard) {
47
+ try {
48
+ mkdirSync(dirname(DOCKER_FILE), { recursive: true });
49
+ writeFileSync(DOCKER_FILE, JSON.stringify({ wireguard }) + "\n");
50
+ }
51
+ catch { } // not being able to remember isn't worth failing the scan
52
+ }
53
+ /** The Windscribe login remembered for the docker containers; null when there's none */
54
+ export function loadLogin() {
55
+ try {
56
+ const { username, password } = JSON.parse(readFileSync(LOGIN_FILE, "utf8"));
57
+ return typeof username === "string" && typeof password === "string" ? { username, password } : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Remembers the login, or forgets it (null) */
64
+ export function saveLogin(c) {
65
+ try {
66
+ rmSync(LOGIN_FILE, { force: true }); // a new file gets the mode below; an old one would keep its own
67
+ if (!c)
68
+ return;
69
+ mkdirSync(dirname(LOGIN_FILE), { recursive: true });
70
+ writeFileSync(LOGIN_FILE, JSON.stringify(c) + "\n", { mode: 0o600 });
71
+ }
72
+ catch { } // not being able to remember isn't worth failing the scan
73
+ }