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 +48 -2
- package/dist/cli.js +43 -11
- package/dist/config.js +27 -2
- package/dist/docker.js +330 -0
- package/dist/probe.js +10 -10
- package/dist/scan.js +70 -31
- package/dist/tui/docker.js +166 -0
- package/dist/tui/picker.js +60 -18
- package/dist/tui/progress.js +27 -13
- package/dist/windscribe.js +48 -2
- package/dist/worker.js +75 -0
- package/package.json +5 -3
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
|
|
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:
|
|
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 >
|
|
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},
|
|
49
|
-
if (
|
|
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
|
-
|
|
139
|
-
const
|
|
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
|
+
}
|