windscout 0.5.8 → 0.6.1

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
 
@@ -53,6 +54,7 @@ Run `windscout` with no arguments to open the interactive picker:
53
54
  | `s` | toggle the speed test (off = connect-only mode) |
54
55
  | `f` | toggle "block internet while scanning" (firewall) |
55
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) |
56
58
  | `u` | update to the newer version and restart (only when the picker shows one is out) |
57
59
  | `enter` | start scanning |
58
60
  | `q` | quit (during a scan: stop and show partial results; press again to force quit) |
@@ -61,7 +63,7 @@ Run `windscout` with no arguments to open the interactive picker:
61
63
 
62
64
  Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
63
65
 
64
- 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.
65
67
 
66
68
  ### Non-interactive
67
69
 
@@ -82,6 +84,7 @@ windscout -t 40 -a # longer connect timeout, also list
82
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 |
83
85
  | `-f, --firewall` | block internet while scanning, except through the VPN | picker: saved, or off; with `-c`: off |
84
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 |
85
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 | |
86
89
 
87
90
  ## Connect-only mode
@@ -109,6 +112,48 @@ While scanning, press `tab` to switch between the availability grid and the spee
109
112
 
110
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.
111
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:** the containers' Windscribe apps log in with the session that the Windscribe app on this computer is logged in with, so they never log in with your password. Windscribe locks an account that logs in many times at once, and a login per container would do that. WindScout reads the session from the app's settings (`~/.config/Windscribe/Windscribe2.conf` on Linux, the `com.windscribe.Windscribe2` defaults on macOS, `HKEY_CURRENT_USER\Software\Windscribe\Windscribe2` in the registry on Windows) and hands it to each container on its standard input, not on a command line, where other programs could see it. Each container's docker volume (`windscout-1`, `windscout-2`, …) keeps its app's settings for the next scans, with the WireGuard keys it registered with your account. A volume that another account used starts over. If you log out of the Windscribe app, the containers' session ends too, and the next scan gives them the new one.
144
+
145
+ WindScout 0.6.0 asked for your username and password instead, and could save them in `~/.config/windscout/login.json`. WindScout now deletes that file.
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 their settings too (and the session in them), run `docker volume rm windscout-1 windscout-2 …`. To remove the image, run `docker rmi windscout-worker`.
156
+
112
157
  ## Output
113
158
 
114
159
  While scanning:
@@ -144,7 +189,7 @@ At the end, the results, a per-protocol summary and the best pick are printed to
144
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.
145
190
  - **A full scan takes a while.** The worst case is countries × protocols × timeout. Narrow it with `-c` and `-p`.
146
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.
147
- - **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.
148
193
 
149
194
  ## Development
150
195
 
@@ -153,6 +198,7 @@ The source is TypeScript in `src/`. It compiles to `dist/`, which is what gets p
153
198
  ```sh
154
199
  npm install
155
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)
156
202
  node dist/cli.js # run your build; takes the same options as windscout
157
203
  ./publish.sh # build, install the packed tarball to check it runs, publish to npm (bump the version first)
158
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, forgetLogin, 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.
@@ -28,6 +32,7 @@ Options:
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)
30
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)
31
36
  -v, --version show the installed version (and offer to update if a newer one is out)
32
37
  -h, --help show this help
33
38
 
@@ -37,18 +42,18 @@ Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
37
42
  const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
38
43
  const ask = (question) => new Promise((resolve) => {
39
44
  const rl = createInterface({ input: process.stdin, output: out });
40
- rl.on("SIGINT", () => rl.close()); // Ctrl-C / Ctrl-D: same as enter
41
- rl.on("close", () => resolve(""));
45
+ rl.on("SIGINT", () => rl.close()); // Ctrl-C / Ctrl-D: no answer, null
46
+ rl.on("close", () => resolve(null));
42
47
  rl.question(question, (a) => (resolve(a.trim()), rl.close()));
43
48
  });
44
- /** 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 */
45
50
  async function offerConnect(choices, timeout) {
46
- 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" : "";
47
52
  for (;;) {
48
- const a = await ask(`\nconnect to # (0 = best${others}, enter to quit): `);
49
- 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))
50
55
  return;
51
- const r = /^\d+$/.test(a) ? choices[Number(a)] : undefined;
56
+ const r = a === "" ? choices[0] : /^\d+$/.test(a) ? choices[Number(a)] : undefined;
52
57
  if (!r) {
53
58
  console.log(yellow(`no #${a} in the tables`));
54
59
  continue;
@@ -85,15 +90,22 @@ async function main() {
85
90
  "no-speed": { type: "boolean", short: "n", default: false },
86
91
  firewall: { type: "boolean", short: "f", default: false },
87
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
88
95
  },
89
96
  });
97
+ if (a.worker) {
98
+ await worker();
99
+ process.exit(); // its stdin may still be open: its app didn't log in
100
+ }
101
+ forgetLogin();
90
102
  if (a.help)
91
103
  return console.log(HELP);
92
104
  checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
93
105
  if (a.version) {
94
106
  await checkUpdate();
95
107
  console.log(versionLines().join("\n"));
96
- if (canUpdate() && process.stdin.isTTY && out.isTTY && /^y(es)?$/i.test(await ask("update now? (y/n) ")))
108
+ if (canUpdate() && process.stdin.isTTY && out.isTTY && /^y(es)?$/i.test((await ask("update now? (y/n) ")) ?? ""))
97
109
  process.exitCode = runUpdate();
98
110
  return;
99
111
  }
@@ -107,6 +119,7 @@ async function main() {
107
119
  let speedTest = !a["no-speed"];
108
120
  let firewall = a.firewall;
109
121
  let random = a.random;
122
+ let docker = a.docker;
110
123
  if (!countries) {
111
124
  if (!tty)
112
125
  throw new Error("no terminal for the picker: pass countries with -c");
@@ -119,6 +132,7 @@ async function main() {
119
132
  // already on in the app: show it on, since the scan won't turn it off
120
133
  firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
121
134
  random: random || (saved?.random ?? false),
135
+ docker: docker || (saved?.docker ?? false),
122
136
  });
123
137
  if (!choice)
124
138
  return;
@@ -130,17 +144,35 @@ async function main() {
130
144
  firewall = choice.firewall;
131
145
  random = choice.random;
132
146
  timeout = choice.timeout;
147
+ docker = choice.docker;
133
148
  }
134
149
  protocols ??= PROTOCOLS;
135
150
  timeout ??= DEFAULT_TIMEOUT;
136
151
  if (random)
137
152
  countries = countries.map((c) => [Math.random(), c]).sort(([x], [y]) => x - y).map(([, c]) => c);
138
- const scan = new Scan(countries, protocols, { timeout, speedTest });
139
- const blocking = firewall && (await blockInternet());
153
+ let probers = [probe], after; // after: what runs here once the containers are done
154
+ const later = notInContainers(protocols, loadDockerWireGuard()); // as far as the last parallel scan knows
155
+ const inContainers = countries.length * protocols.filter((p) => !later.has(p)).length;
156
+ if (docker && inContainers) {
157
+ const started = await startParallel(inContainers);
158
+ if (!started)
159
+ return leaveScreen();
160
+ saveDockerWireGuard(started.wireguard);
161
+ probers = started.probers;
162
+ after = { prober: probe, protocols: notInContainers(protocols, started.wireguard), name: "this computer" };
163
+ await windscribe("disconnect"); // a VPN connection here would carry the containers' traffic too
164
+ }
165
+ const scan = new Scan(countries, protocols, { timeout, speedTest }, probers, after);
166
+ // not with containers: their traffic goes out through this computer, and the firewall would block it
167
+ const blocking = firewall && !after && (await blockInternet());
140
168
  const view = tty ? showProgress(scan, blocking) : logProgress(scan);
141
169
  await scan.run(view);
170
+ if (after)
171
+ await stopWorkers();
142
172
  await restoreFirewall();
143
173
  view.close();
174
+ for (const e of new Set(scan.errors))
175
+ console.error(yellow(e));
144
176
  if (scan.stopping)
145
177
  console.log(yellow("interrupted, showing partial results"));
146
178
  const choices = report(scan.results, protocols, { all: a.all, speedTest });
package/dist/config.js CHANGED
@@ -1,11 +1,12 @@
1
- // The picker's selection, remembered between runs.
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ // The picker's selection, and what the last parallel scan found its containers can do, 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
+ const DOCKER_FILE = join(dirname(CONFIG), "docker.json"); // what the last parallel scan found its containers can do
9
10
  /** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
10
11
  export function loadConfig() {
11
12
  try {
@@ -17,6 +18,7 @@ export function loadConfig() {
17
18
  firewall: c.firewall === true,
18
19
  random: c.random === true,
19
20
  timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
21
+ docker: c.docker === true,
20
22
  };
21
23
  }
22
24
  catch {
@@ -30,3 +32,26 @@ export function saveConfig(c) {
30
32
  }
31
33
  catch { } // not being able to remember isn't worth failing the scan
32
34
  }
35
+ /** Whether WireGuard worked in the docker containers last time (yes until a scan finds otherwise), for the picker's estimate */
36
+ export function loadDockerWireGuard() {
37
+ try {
38
+ return JSON.parse(readFileSync(DOCKER_FILE, "utf8")).wireguard !== false;
39
+ }
40
+ catch {
41
+ return true;
42
+ }
43
+ }
44
+ export function saveDockerWireGuard(wireguard) {
45
+ try {
46
+ mkdirSync(dirname(DOCKER_FILE), { recursive: true });
47
+ writeFileSync(DOCKER_FILE, JSON.stringify({ wireguard }) + "\n");
48
+ }
49
+ catch { } // not being able to remember isn't worth failing the scan
50
+ }
51
+ /** Deletes the Windscribe username and password that 0.6.0 saved for the docker containers: they log in with this computer's session now */
52
+ export function forgetLogin() {
53
+ try {
54
+ rmSync(join(dirname(CONFIG), "login.json"), { force: true });
55
+ }
56
+ catch { } // not being able to delete it isn't worth failing the scan
57
+ }
package/dist/docker.js ADDED
@@ -0,0 +1,330 @@
1
+ // Parallel scans: a docker container per slot, each with its own Windscribe app, so each has its own tunnel.
2
+ // The apps log in with the session this computer's app is logged in with.
3
+ // A container runs `windscout --worker` (worker.ts) and takes attempts from here one at a time:
4
+ // a JSON object per line on its stdin, answered the same way on its stdout.
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
8
+ import { tmpdir, userInfo } from "node:os";
9
+ import { delimiter, dirname, join } from "node:path";
10
+ import { createInterface } from "node:readline";
11
+ import { fileURLToPath } from "node:url";
12
+ import { sleep } from "./time.js";
13
+ const IMAGE = "windscout-worker";
14
+ const ROOT = new URL("..", import.meta.url); // this package: its package.json and dist/ go into the image
15
+ // The image's base, node:22-bookworm-slim pinned by digest: whichever registry serves it, it's exactly this image.
16
+ // ponytail: pinned; refresh it now and then (docker buildx imagetools inspect node:22-bookworm-slim) for Node and Debian fixes
17
+ const BASE = "library/node@sha256:3ed98443660ff48413b5af19ecefce3b9d50845a901a43420d47cc63a9fddbe9";
18
+ const BASE_TAG = "windscout-base:node22-3ed98443"; // BASE once pulled, from wherever
19
+ // Docker Hub, then mirrors of it in Iran, where Docker Hub refuses to serve (US sanctions). From github.com/MiravaOrg/Mirava:
20
+ // the first six after Docker Hub served BASE on 2026-10-21. WINDSCOUT_REGISTRY_MIRRORS (comma-separated hosts) go first.
21
+ // ponytail: mirrors come and go; refresh the list from Mirava when they do
22
+ const REGISTRIES = [...(process.env.WINDSCOUT_REGISTRY_MIRRORS?.split(",").map((h) => h.trim()).filter(Boolean) ?? []),
23
+ "docker.io", "registry.docker.ir", "docker.iranserver.com", "docker.kernel.ir", "mirror2.chabokan.net", "docker-mirror.liara.ir",
24
+ "hub.hamdocker.ir", "docker.arvancloud.ir", "focker.ir", "docker.mobinhost.com", "mirror-docker.runflare.com"];
25
+ // Debian's packages: deb.debian.org, else its mirrors in Iran (from Mirava; apt checks Debian's signatures, whoever serves them).
26
+ // Over http: the base image has no CA certificates yet.
27
+ // Windscribe's Linux CLI app: its latest stable release on GitHub, found through the releases/latest redirect, not GitHub's API,
28
+ // whose 60 requests an hour per address run out behind shared addresses (as on Iran's mobile networks).
29
+ // openresolv: the app sets the tunnel's DNS through resolvconf; without it, DNS goes nowhere once connected.
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
+ // 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.
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
+ const DOCKERFILE = `FROM ${BASE_TAG}
35
+ ENV LANG=C.UTF-8
36
+ RUN for m in deb.debian.org mirror.shatel.ir mirror.iranserver.com mirror.mobinhost.com mirror2.chabokan.net; do \\
37
+ sed -i -E "s#^URIs: [a-z]+://[^/]+/#URIs: http://$m/#" /etc/apt/sources.list.d/debian.sources; \\
38
+ apt-get -o Acquire::http::Timeout=20 update --error-on=any && break; \\
39
+ done \\
40
+ && apt-get install -y --no-install-recommends ca-certificates curl openresolv \\
41
+ && v=$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/Windscribe/Desktop-App/releases/latest | sed 's#.*/v##') \\
42
+ && curl -fsSL -o /tmp/windscribe.deb "https://github.com/Windscribe/Desktop-App/releases/download/v$v/windscribe-cli_"$v"_$(dpkg --print-architecture).deb" \\
43
+ && apt-get install -y --no-install-recommends /tmp/windscribe.deb \\
44
+ && rm -rf /tmp/windscribe.deb /var/lib/apt/lists/* \\
45
+ && useradd -m ws && mkdir -p /home/ws/.config/Windscribe && chown -R ws: /home/ws
46
+ COPY package.json /app/
47
+ COPY dist /app/dist
48
+ CMD mkdir -p /dev/net && { [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200; } \\
49
+ && ip tuntap add mode tun dev wscheck && ip link del wscheck \\
50
+ && nft 'add table inet wscheck; add chain inet wscheck o { type filter hook output priority 0; }; add rule inet wscheck o ct mark 1 reject; delete table inet wscheck' \\
51
+ || { echo "this docker's Linux kernel can't give a container a VPN tunnel (tun) and firewall (nftables)" >&2; exit 1; }; \\
52
+ nft 'add table inet wscheck; add chain inet wscheck p { type filter hook prerouting priority 0; }; add rule inet wscheck p fib saddr type != local drop; delete table inet wscheck' 2>/dev/null \\
53
+ && wg=1 || wg=0; \\
54
+ /opt/windscribe/helper >/dev/null 2>&1 & \\
55
+ exec runuser -u ws -- env HOME=/home/ws WS_WIREGUARD=$wg node /app/dist/cli.js --worker
56
+ `;
57
+ const user = userInfo().username;
58
+ // Docker Desktop, and the docker CLI in it: used when docker isn't on PATH (a fresh install on Windows isn't, for this process)
59
+ const DD = join(process.env.ProgramFiles || "C:\\Program Files", "Docker", "Docker");
60
+ const [DESKTOP, DESKTOP_CLI] = {
61
+ darwin: ["/Applications/Docker.app", "/Applications/Docker.app/Contents/Resources/bin/docker"],
62
+ win32: [join(DD, "Docker Desktop.exe"), join(DD, "resources", "bin", "docker.exe")],
63
+ }[process.platform] ?? [];
64
+ /** Where to install docker by hand, when installing it from here didn't work */
65
+ export const INSTALL_DOCS = {
66
+ darwin: "https://docs.docker.com/desktop/setup/install/mac-install/",
67
+ win32: "https://docs.docker.com/desktop/setup/install/windows-install/",
68
+ }[process.platform] ?? "https://docs.docker.com/engine/install/";
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 } = {}) {
72
+ return new Promise((resolve) => {
73
+ const p = args ? spawn(cmd, args, { timeout }) : spawn(cmd, { shell: true, timeout });
74
+ let out = "";
75
+ const take = (d) => {
76
+ out = (out + d).slice(-20_000);
77
+ String(d).split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean).forEach(onLine);
78
+ };
79
+ p.stdout.on("data", take);
80
+ p.stderr.on("data", take);
81
+ p.stdin.on("error", () => { });
82
+ p.stdin.end(input);
83
+ p.on("error", (e) => resolve({ code: -1, out: e.message }));
84
+ p.on("close", (code) => resolve({ code: code ?? 1, out }));
85
+ });
86
+ }
87
+ /** Runs the docker CLI. Not on PATH: Docker Desktop's folder goes on it, for the CLI and the credential helper beside it */
88
+ async function docker(args, options) {
89
+ const r = await exec("docker", args, options);
90
+ if (r.code !== -1 || !DESKTOP_CLI || !existsSync(DESKTOP_CLI) || process.env.PATH?.split(delimiter).includes(dirname(DESKTOP_CLI)))
91
+ return r;
92
+ process.env.PATH = dirname(DESKTOP_CLI) + delimiter + process.env.PATH;
93
+ return exec("docker", args, options);
94
+ }
95
+ let info = { cpus: 0, memory: 0, os: "linux" }; // docker's, from the last dockerState() that found it ready
96
+ /** ready: the daemon answers us. denied: it runs, but this user may not use it (Linux: not in the docker group yet) */
97
+ export async function dockerState() {
98
+ const r = await docker(["info", "--format", "{{.NCPU}} {{.MemTotal}} {{.OSType}}"], { timeout: 30_000 });
99
+ if (r.code === -1)
100
+ return DESKTOP && existsSync(DESKTOP) ? "stopped" : "missing";
101
+ if (r.code)
102
+ 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 };
105
+ return "ready";
106
+ }
107
+ const WORKER_MEMORY = 250e6; // bytes per container: Windscribe's app and helper, a tunnel and node take ~120 MB
108
+ const MAX_WORKERS = 8; // ponytail: past this, more containers mostly add load on the one uplink; raise if real scans say so
109
+ /** Protocols the containers can't do, which a parallel scan runs on this computer after them: IKEv2 (Windscribe's Linux app
110
+ * has none), and WireGuard when their kernel can't run its routing (see DOCKERFILE) */
111
+ export const notInContainers = (protocols, wireguard) => new Set(protocols.filter((p) => p === "ikev2" || (p === "wireguard" && !wireguard)));
112
+ /** Containers for a scan of this many attempts: one per attempt, within docker's CPUs and 3/4 of its memory */
113
+ export const workerCount = (attempts) => Math.max(1, Math.min(attempts, MAX_WORKERS, info.cpus, Math.floor((info.memory * 0.75) / WORKER_MEMORY)));
114
+ const DMG = `https://desktop.docker.com/mac/main/${process.arch === "arm64" ? "arm64" : "amd64"}/Docker.dmg`;
115
+ const START_DESKTOP = process.platform === "win32"
116
+ ? { label: "start Docker Desktop", cmd: () => `start "" "${DESKTOP}"` }
117
+ : { label: "start Docker Desktop", cmd: () => "open -a Docker" };
118
+ // docker.com refuses some countries (Iran: US sanctions): then the distribution's own docker package, from the mirrors it uses.
119
+ // get.docker.com may have left its repository behind: without that gone, the package manager would fail on it
120
+ const DISTRO_DOCKER = "rm -f /etc/apt/sources.list.d/docker.list /etc/apt/sources.list.d/docker.sources /etc/yum.repos.d/docker-ce.repo; "
121
+ + "if command -v apt-get >/dev/null; then apt-get update; apt-get install -y docker.io && { apt-get install -y docker-buildx || true; }; "
122
+ + "elif command -v dnf >/dev/null; then dnf install -y moby-engine; "
123
+ + "elif command -v pacman >/dev/null; then pacman -Sy --noconfirm docker docker-buildx; "
124
+ + "elif command -v zypper >/dev/null; then zypper --non-interactive install docker; "
125
+ + "else echo 'no package manager WindScout knows here: install docker yourself' >&2; false; fi";
126
+ // Linux: members of the docker group control docker, root-equivalent; that's what lets us run it without sudo
127
+ const JOIN = { label: "add you to the docker group, and start docker", root: true,
128
+ cmd: () => `usermod -aG docker "${user}" && (systemctl enable --now docker || service docker start)` };
129
+ /** What it takes to get docker answering, from this state */
130
+ export function steps(state) {
131
+ if (process.platform === "linux")
132
+ return state === "missing" ? [
133
+ { label: "install docker from get.docker.com, or else your distribution's package", root: true,
134
+ cmd: (d) => `{ curl -fsSL --max-time 60 -o "${d}/get-docker.sh" https://get.docker.com && sh "${d}/get-docker.sh"; } || { ${DISTRO_DOCKER}; }` },
135
+ JOIN,
136
+ ] : state === "denied" ? [JOIN] : [{ label: "start docker", root: true, cmd: () => "systemctl start docker || service docker start" }];
137
+ if (state !== "missing")
138
+ return [START_DESKTOP];
139
+ return process.platform === "win32" ? [
140
+ { label: "install Docker Desktop with winget (Windows asks for permission, and may want a restart)",
141
+ cmd: () => "winget install -e --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements" },
142
+ START_DESKTOP,
143
+ ] : [
144
+ { label: "download Docker Desktop from docker.com (about 600 MB)", cmd: (d) => `curl -fL -o "${d}/Docker.dmg" ${DMG}` },
145
+ { label: "install it in /Applications, accepting its license", root: true,
146
+ cmd: (d) => `hdiutil attach -nobrowse -quiet -mountpoint "${d}/mnt" "${d}/Docker.dmg"`
147
+ + ` && "${d}/mnt/Docker.app/Contents/MacOS/install" --accept-license --user="${user}"; s=$?; hdiutil detach -quiet "${d}/mnt"; exit $s` },
148
+ START_DESKTOP,
149
+ ];
150
+ }
151
+ /** sudo runs without asking right now (a recent password, or NOPASSWD) */
152
+ export const sudoReady = async () => (await exec("sudo", ["-n", "true"])).code === 0;
153
+ /** Gives sudo the password, so the root steps can run with sudo -n; "" when it took it, else why not */
154
+ export async function sudoLogin(password) {
155
+ // -S: the password from stdin, -v: only validate. One line, so a typo counts as one failed try, not three
156
+ const r = await exec("sudo", ["-S", "-p", "", "-v"], { input: password + "\n" });
157
+ return !r.code ? "" : /incorrect|sorry/i.test(r.out) ? "wrong password, try again" : lastLine(r.out) || "sudo refused";
158
+ }
159
+ /** Runs the steps; root ones with sudo -n, after sudoLogin() (password: to log in again if that ran out meanwhile) */
160
+ export async function runSteps(todo, password, onStep, onLine) {
161
+ const dir = mkdtempSync(join(tmpdir(), "windscout-"));
162
+ try {
163
+ for (const s of todo) {
164
+ onStep(s.label);
165
+ if (s.root && !(await sudoReady()) && (!password || (await sudoLogin(password))))
166
+ throw new Error(`sudo wants your password again: try again`);
167
+ const r = await (s.root ? exec("sudo", ["-n", "sh", "-c", s.cmd(dir)], { onLine }) : exec(s.cmd(dir), null, { onLine }));
168
+ if (r.code)
169
+ throw new Error(`${s.label} failed: ${lastLine(r.out) || `exit ${r.code}`}`);
170
+ }
171
+ }
172
+ finally {
173
+ try {
174
+ rmSync(dir, { recursive: true, force: true });
175
+ }
176
+ catch { } // a leftover temp folder isn't worth failing over
177
+ }
178
+ }
179
+ /** Waits for the daemon after starting it; Docker Desktop can take a minute or two the first time */
180
+ export async function waitReady() {
181
+ for (let i = 0; i < 90; i++) {
182
+ const state = await dockerState();
183
+ if (state === "ready")
184
+ return;
185
+ if (state === "denied")
186
+ throw new Error("docker runs now, but your user can't use it yet: try again to fix that");
187
+ await sleep(2000);
188
+ }
189
+ throw new Error("docker didn't start within 3 minutes. If Docker Desktop asks for something (on Windows: a restart, or WSL), do that, then try again");
190
+ }
191
+ /** Hash of what goes into the image (dist/ holds this file, so the Dockerfile too): another build of windscout gets a new image */
192
+ function fingerprint() {
193
+ const h = createHash("sha256");
194
+ const files = ["package.json", ...readdirSync(new URL("dist", ROOT), { recursive: true, encoding: "utf8" }).map((f) => `dist/${f}`)].sort();
195
+ for (const f of files)
196
+ if (statSync(new URL(f, ROOT)).isFile())
197
+ h.update(f).update(readFileSync(new URL(f, ROOT)));
198
+ return h.digest("hex").slice(0, 16);
199
+ }
200
+ /** Whether a registry answers at all: 200 or 401 from its API (Docker Hub says 403 to Iran) */
201
+ const answers = (registry) => fetch(`https://${registry === "docker.io" ? "registry-1.docker.io" : registry}/v2/`, { signal: AbortSignal.timeout(8000) })
202
+ .then((r) => r.status === 200 || r.status === 401, () => false);
203
+ /** Gets the image's base: from the first registry in REGISTRIES that answers and serves it */
204
+ async function pullBase(onLine) {
205
+ if (!(await docker(["image", "inspect", BASE_TAG])).code)
206
+ return;
207
+ const up = await Promise.all(REGISTRIES.map(answers)); // asking them all at once: dead ones would each take the timeout
208
+ // Docker Hub last even if it didn't answer: docker may reach it through a proxy that this process doesn't use
209
+ const order = [...REGISTRIES.filter((_, i) => up[i]), ...(up[REGISTRIES.indexOf("docker.io")] ? [] : ["docker.io"])];
210
+ let why = "none answered";
211
+ for (const registry of order) {
212
+ onLine(`the base image, from ${registry}`);
213
+ const ref = `${registry}/${BASE}`;
214
+ const r = await docker(["pull", ref], { onLine, timeout: 15 * 60_000 });
215
+ if (!r.code)
216
+ return void (await docker(["tag", ref, BASE_TAG]));
217
+ why = `${registry}: ${lastLine(r.out)}`;
218
+ }
219
+ throw new Error(`couldn't get the containers' base image from Docker Hub or its mirrors (${why}). A VPN may help`);
220
+ }
221
+ /** Builds the containers' image, unless this windscout's is there already; onLine gets the build's output */
222
+ export async function buildImage(onLine) {
223
+ if (info.os === "windows")
224
+ throw new Error("Docker Desktop runs Windows containers: switch it to Linux containers (right-click its icon by the clock), then try again");
225
+ const tag = fingerprint();
226
+ const have = await docker(["image", "inspect", "-f", '{{index .Config.Labels "windscout"}}', IMAGE]);
227
+ if (!have.code && have.out.trim() === tag)
228
+ return;
229
+ await pullBase(onLine);
230
+ // no --progress=plain: when piped, BuildKit prints plain anyway, and a distribution's docker may have only the old builder
231
+ const r = await docker(["build", "--label", `windscout=${tag}`, "-t", IMAGE, "-f", "-", fileURLToPath(ROOT).replace(/[\\/]$/, "")], { input: DOCKERFILE, onLine });
232
+ if (r.code)
233
+ throw new Error(`building the containers' image failed: ${lastLine(r.out)}`);
234
+ }
235
+ // one at a time: `await lock()` resolves to the function that lets the next one in
236
+ function mutex() {
237
+ let last = Promise.resolve();
238
+ return () => new Promise((enter) => {
239
+ last = last.then(() => new Promise((leave) => enter(leave)));
240
+ });
241
+ }
242
+ const running = new Map(); // container name → its `docker run`, until the container stops
243
+ // a crash or a force quit leaves them running: remove them
244
+ const removeAll = () => running.size && spawnSync("docker", ["rm", "-f", ...running.keys()], { stdio: "ignore" });
245
+ process.on("exit", removeAll);
246
+ /** Starts n containers, their apps logged in with this computer's session; resolves to a prober for each container that got
247
+ * ready, and whether WireGuard works in them */
248
+ export async function startWorkers(n, session, onStatus) {
249
+ const measuring = mutex();
250
+ let wireguard = true;
251
+ let ready = 0;
252
+ const start = (i) => new Promise((resolve) => {
253
+ const name = `windscout-${process.pid}-${i + 1}`;
254
+ // its own volume keeps its app's settings for the next scans: its WireGuard keys, registered with Windscribe once
255
+ // it makes /dev/net/tun itself: Docker's Linux VM may have none (WSL loads tun as a module), and --device needs one there
256
+ const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name, "--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
257
+ "--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)
258
+ running.set(name, p);
259
+ const send = (m) => p.stdin.write(JSON.stringify(m) + "\n");
260
+ p.stdin.on("error", () => { }); // it stopped: "close" deals with that
261
+ send({ session }); // on stdin, not in the command line or the environment, where ps and docker inspect show it
262
+ let on = (_) => { }; // the current phase's message handler
263
+ let fail = (_) => { }; // and what it does when the container stops
264
+ let dead = "", err = "";
265
+ p.stderr.on("data", (d) => (err = (err + d).slice(-2000)));
266
+ createInterface({ input: p.stdout }).on("line", (l) => {
267
+ let m;
268
+ try {
269
+ m = JSON.parse(l);
270
+ }
271
+ catch {
272
+ return; // not ours
273
+ }
274
+ on(m);
275
+ });
276
+ const gone = (why) => {
277
+ if (dead)
278
+ return;
279
+ dead = why;
280
+ running.delete(name);
281
+ fail(why);
282
+ };
283
+ p.on("error", (e) => gone(e.message));
284
+ p.on("close", (code) => gone(lastLine(err) || `exited (${code})`));
285
+ const prober = (query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
286
+ if (dead)
287
+ return reject(new Error(`container ${i + 1} stopped: ${dead}`));
288
+ let leave = null, over = false;
289
+ const tick = setInterval(() => stopped() && send({ stop: true }), 1000);
290
+ const end = () => ((over = true), clearInterval(tick), leave?.());
291
+ fail = (why) => (end(), reject(new Error(`container ${i + 1} stopped: ${why}`)));
292
+ on = async (m) => {
293
+ if (m.step)
294
+ onStep(m.step);
295
+ else if (m.measure) {
296
+ onStep("waiting for the other containers to measure");
297
+ const l = await measuring();
298
+ if (over)
299
+ l();
300
+ else
301
+ (leave = l), send({ go: true });
302
+ }
303
+ else if (m.measured)
304
+ leave?.(), (leave = null);
305
+ else if (m.result)
306
+ end(), done(m.result);
307
+ };
308
+ send({ probe: { query, protocol, timeout, speedTest } });
309
+ });
310
+ fail = (why) => (onStatus(`container ${i + 1}: ${why}`), resolve(null));
311
+ on = (m) => {
312
+ if (!m.ready)
313
+ return;
314
+ wireguard &&= m.wireguard !== false;
315
+ fail = () => { };
316
+ onStatus(`${++ready} of ${n} ready`);
317
+ resolve(prober);
318
+ };
319
+ });
320
+ const probers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((p) => p !== null);
321
+ return { probers, wireguard };
322
+ }
323
+ /** Ends the containers once the scan is over: a worker exits when its stdin closes, and docker removes its container */
324
+ export async function stopWorkers() {
325
+ for (const p of running.values())
326
+ p.stdin?.end();
327
+ for (let i = 0; running.size && i < 40; i++)
328
+ await sleep(250);
329
+ removeAll(); // stuck ones: their docker run would keep us from exiting
330
+ }