windscout 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/cli.js +42 -11
- package/dist/config.js +43 -2
- package/dist/docker.js +349 -0
- package/dist/probe.js +10 -10
- package/dist/scan.js +70 -31
- package/dist/tui/docker.js +241 -0
- package/dist/tui/picker.js +60 -18
- package/dist/tui/progress.js +27 -13
- package/dist/worker.js +92 -0
- package/package.json +5 -3
package/dist/docker.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Parallel scans: a docker container per slot, each with its own Windscribe app, so each has its own tunnel.
|
|
2
|
+
// A container runs `windscout --worker` (worker.ts) and takes attempts from here one at a time:
|
|
3
|
+
// a JSON object per line on its stdin, answered the same way on its stdout.
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
7
|
+
import { tmpdir, userInfo } from "node:os";
|
|
8
|
+
import { delimiter, dirname, join } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { sleep } from "./time.js";
|
|
12
|
+
const IMAGE = "windscout-worker";
|
|
13
|
+
const ROOT = new URL("..", import.meta.url); // this package: its package.json and dist/ go into the image
|
|
14
|
+
// The image's base, node:22-bookworm-slim pinned by digest: whichever registry serves it, it's exactly this image.
|
|
15
|
+
// ponytail: pinned; refresh it now and then (docker buildx imagetools inspect node:22-bookworm-slim) for Node and Debian fixes
|
|
16
|
+
const BASE = "library/node@sha256:3ed98443660ff48413b5af19ecefce3b9d50845a901a43420d47cc63a9fddbe9";
|
|
17
|
+
const BASE_TAG = "windscout-base:node22-3ed98443"; // BASE once pulled, from wherever
|
|
18
|
+
// Docker Hub, then mirrors of it in Iran, where Docker Hub refuses to serve (US sanctions). From github.com/MiravaOrg/Mirava:
|
|
19
|
+
// the first six after Docker Hub served BASE on 2026-10-21. WINDSCOUT_REGISTRY_MIRRORS (comma-separated hosts) go first.
|
|
20
|
+
// ponytail: mirrors come and go; refresh the list from Mirava when they do
|
|
21
|
+
const REGISTRIES = [...(process.env.WINDSCOUT_REGISTRY_MIRRORS?.split(",").map((h) => h.trim()).filter(Boolean) ?? []),
|
|
22
|
+
"docker.io", "registry.docker.ir", "docker.iranserver.com", "docker.kernel.ir", "mirror2.chabokan.net", "docker-mirror.liara.ir",
|
|
23
|
+
"hub.hamdocker.ir", "docker.arvancloud.ir", "focker.ir", "docker.mobinhost.com", "mirror-docker.runflare.com"];
|
|
24
|
+
// Debian's packages: deb.debian.org, else its mirrors in Iran (from Mirava; apt checks Debian's signatures, whoever serves them).
|
|
25
|
+
// Over http: the base image has no CA certificates yet.
|
|
26
|
+
// Windscribe's Linux CLI app: its latest stable release on GitHub, found through the releases/latest redirect, not GitHub's API,
|
|
27
|
+
// whose 60 requests an hour per address run out behind shared addresses (as on Iran's mobile networks).
|
|
28
|
+
// openresolv: the app sets the tunnel's DNS through resolvconf; without it, DNS goes nowhere once connected.
|
|
29
|
+
// The CMD first checks the kernel (WSL's builds tun and parts of nftables as modules): no tun or nftables, no tunnel, so
|
|
30
|
+
// the container stops saying so instead of scanning. The helper's WireGuard routing also puts a fib rule in an inet table,
|
|
31
|
+
// which Docker Desktop's own kernel (macOS, Windows without WSL) can't run: WS_WIREGUARD=0 then, and it runs on this computer.
|
|
32
|
+
// No systemd in a container: start the helper (root) and the app (as a user: its CLI refuses root) like their units would
|
|
33
|
+
const DOCKERFILE = `FROM ${BASE_TAG}
|
|
34
|
+
ENV LANG=C.UTF-8
|
|
35
|
+
RUN for m in deb.debian.org mirror.shatel.ir mirror.iranserver.com mirror.mobinhost.com mirror2.chabokan.net; do \\
|
|
36
|
+
sed -i -E "s#^URIs: [a-z]+://[^/]+/#URIs: http://$m/#" /etc/apt/sources.list.d/debian.sources; \\
|
|
37
|
+
apt-get -o Acquire::http::Timeout=20 update --error-on=any && break; \\
|
|
38
|
+
done \\
|
|
39
|
+
&& apt-get install -y --no-install-recommends ca-certificates curl openresolv \\
|
|
40
|
+
&& v=$(curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/Windscribe/Desktop-App/releases/latest | sed 's#.*/v##') \\
|
|
41
|
+
&& curl -fsSL -o /tmp/windscribe.deb "https://github.com/Windscribe/Desktop-App/releases/download/v$v/windscribe-cli_"$v"_$(dpkg --print-architecture).deb" \\
|
|
42
|
+
&& apt-get install -y --no-install-recommends /tmp/windscribe.deb \\
|
|
43
|
+
&& rm -rf /tmp/windscribe.deb /var/lib/apt/lists/* \\
|
|
44
|
+
&& useradd -m ws && mkdir -p /home/ws/.config/Windscribe && chown -R ws: /home/ws
|
|
45
|
+
COPY package.json /app/
|
|
46
|
+
COPY dist /app/dist
|
|
47
|
+
CMD mkdir -p /dev/net && { [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200; } \\
|
|
48
|
+
&& ip tuntap add mode tun dev wscheck && ip link del wscheck \\
|
|
49
|
+
&& 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' \\
|
|
50
|
+
|| { echo "this docker's Linux kernel can't give a container a VPN tunnel (tun) and firewall (nftables)" >&2; exit 1; }; \\
|
|
51
|
+
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 \\
|
|
52
|
+
&& wg=1 || wg=0; \\
|
|
53
|
+
/opt/windscribe/helper >/dev/null 2>&1 & \\
|
|
54
|
+
runuser -u ws -- env HOME=/home/ws /opt/windscribe/Windscribe >/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 logins and 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 and logs them in to Windscribe, one at a time; resolves to a prober for each container that got ready,
|
|
247
|
+
* and whether WireGuard works in them */
|
|
248
|
+
export async function startWorkers(n, prompt, onStatus) {
|
|
249
|
+
const measuring = mutex(), loggingIn = mutex();
|
|
250
|
+
let wireguard = true;
|
|
251
|
+
let creds; // undefined: not asked yet; null: the user cancelled
|
|
252
|
+
let ready = 0;
|
|
253
|
+
const credentials = async (error) => {
|
|
254
|
+
if (creds !== null && (error || !creds))
|
|
255
|
+
creds = await prompt.credentials(error);
|
|
256
|
+
return creds;
|
|
257
|
+
};
|
|
258
|
+
const start = (i) => new Promise((resolve) => {
|
|
259
|
+
const name = `windscout-${process.pid}-${i + 1}`;
|
|
260
|
+
// its own volume keeps its Windscribe login for the next scans
|
|
261
|
+
// it makes /dev/net/tun itself: Docker's Linux VM may have none (WSL loads tun as a module), and --device needs one there
|
|
262
|
+
const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name, "--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
|
|
263
|
+
"--sysctl", "net.ipv4.conf.all.src_valid_mark=1", "-v", `windscout-${i + 1}:/home/ws/.config/Windscribe`, IMAGE], { detached: process.platform !== "win32" }); // its own process group, so ctrl-c reaches only us (Windows would open a window)
|
|
264
|
+
running.set(name, p);
|
|
265
|
+
const send = (m) => p.stdin.write(JSON.stringify(m) + "\n");
|
|
266
|
+
p.stdin.on("error", () => { }); // it stopped: "close" deals with that
|
|
267
|
+
let on = (_) => { }; // the current phase's message handler
|
|
268
|
+
let fail = (_) => { }; // and what it does when the container stops
|
|
269
|
+
let dead = "", err = "";
|
|
270
|
+
p.stderr.on("data", (d) => (err = (err + d).slice(-2000)));
|
|
271
|
+
createInterface({ input: p.stdout }).on("line", (l) => {
|
|
272
|
+
let m;
|
|
273
|
+
try {
|
|
274
|
+
m = JSON.parse(l);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return; // not ours
|
|
278
|
+
}
|
|
279
|
+
on(m);
|
|
280
|
+
});
|
|
281
|
+
const gone = (why) => {
|
|
282
|
+
if (dead)
|
|
283
|
+
return;
|
|
284
|
+
dead = why;
|
|
285
|
+
running.delete(name);
|
|
286
|
+
fail(why);
|
|
287
|
+
};
|
|
288
|
+
p.on("error", (e) => gone(e.message));
|
|
289
|
+
p.on("close", (code) => gone(lastLine(err) || `exited (${code})`));
|
|
290
|
+
const prober = (query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) => new Promise((done, reject) => {
|
|
291
|
+
if (dead)
|
|
292
|
+
return reject(new Error(`container ${i + 1} stopped: ${dead}`));
|
|
293
|
+
let leave = null, over = false;
|
|
294
|
+
const tick = setInterval(() => stopped() && send({ stop: true }), 1000);
|
|
295
|
+
const end = () => ((over = true), clearInterval(tick), leave?.());
|
|
296
|
+
fail = (why) => (end(), reject(new Error(`container ${i + 1} stopped: ${why}`)));
|
|
297
|
+
on = async (m) => {
|
|
298
|
+
if (m.step)
|
|
299
|
+
onStep(m.step);
|
|
300
|
+
else if (m.measure) {
|
|
301
|
+
onStep("waiting for the other containers to measure");
|
|
302
|
+
const l = await measuring();
|
|
303
|
+
if (over)
|
|
304
|
+
l();
|
|
305
|
+
else
|
|
306
|
+
(leave = l), send({ go: true });
|
|
307
|
+
}
|
|
308
|
+
else if (m.measured)
|
|
309
|
+
leave?.(), (leave = null);
|
|
310
|
+
else if (m.result)
|
|
311
|
+
end(), done(m.result);
|
|
312
|
+
};
|
|
313
|
+
send({ probe: { query, protocol, timeout, speedTest } });
|
|
314
|
+
});
|
|
315
|
+
let leave = null, used = null, unused = false; // the login lock while logging in; with what; or no login for it
|
|
316
|
+
fail = (why) => (leave?.(), unused || onStatus(`container ${i + 1}: ${why}`), resolve(null));
|
|
317
|
+
on = async (m) => {
|
|
318
|
+
if (m.need === "login") {
|
|
319
|
+
leave ??= await loggingIn();
|
|
320
|
+
const c = await credentials(m.text ?? "");
|
|
321
|
+
if (c)
|
|
322
|
+
(used = c), send({ login: c });
|
|
323
|
+
else
|
|
324
|
+
(unused = true), p.stdin.end(); // none: the worker gives up, its container stops
|
|
325
|
+
}
|
|
326
|
+
else if (m.need === "input")
|
|
327
|
+
send({ input: (await prompt.answer(m.text ?? "")) ?? "" });
|
|
328
|
+
else if (m.ready) {
|
|
329
|
+
leave?.();
|
|
330
|
+
if (used)
|
|
331
|
+
prompt.loggedIn(used);
|
|
332
|
+
wireguard &&= m.wireguard !== false;
|
|
333
|
+
fail = () => { };
|
|
334
|
+
onStatus(`${++ready} of ${n} ready`);
|
|
335
|
+
resolve(prober);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
const probers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((p) => p !== null);
|
|
340
|
+
return { probers, wireguard };
|
|
341
|
+
}
|
|
342
|
+
/** Ends the containers once the scan is over: a worker exits when its stdin closes, and docker removes its container */
|
|
343
|
+
export async function stopWorkers() {
|
|
344
|
+
for (const p of running.values())
|
|
345
|
+
p.stdin?.end();
|
|
346
|
+
for (let i = 0; running.size && i < 40; i++)
|
|
347
|
+
await sleep(250);
|
|
348
|
+
removeAll(); // stuck ones: their docker run would keep us from exiting
|
|
349
|
+
}
|
package/dist/probe.js
CHANGED
|
@@ -4,7 +4,7 @@ import { now, sleep } from "./time.js";
|
|
|
4
4
|
import { connectState, windscribe } from "./windscribe.js";
|
|
5
5
|
// "Connected: Miami - Vice" → "Miami - Vice"
|
|
6
6
|
const place = (state) => state.split(":").slice(1).join(":").trim();
|
|
7
|
-
export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { } }) {
|
|
7
|
+
export async function probe(query, protocol, { timeout, speedTest, stopped = () => false, onStep = () => { }, measure = (fn) => fn() }) {
|
|
8
8
|
const r = { query, protocol, location: "", connectS: null, latencyMs: null, speedMbps: null };
|
|
9
9
|
const end = (status) => ({ ...r, status });
|
|
10
10
|
onStep("connecting");
|
|
@@ -32,15 +32,15 @@ export async function probe(query, protocol, { timeout, speedTest, stopped = ()
|
|
|
32
32
|
onStep(`connecting ${r.location}`.trim());
|
|
33
33
|
await sleep(1000);
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
return end("ok");
|
|
35
|
+
await measure(async () => {
|
|
36
|
+
onStep(`measuring latency · ${r.location}`);
|
|
37
|
+
r.latencyMs = await latencyMs();
|
|
38
|
+
if (r.latencyMs !== null && speedTest) {
|
|
39
|
+
onStep(`measuring speed · ${r.location}`);
|
|
40
|
+
r.speedMbps = await speedMbps();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return end(r.latencyMs === null ? "no internet" : "ok");
|
|
44
44
|
}
|
|
45
45
|
finally {
|
|
46
46
|
onStep("disconnecting");
|
package/dist/scan.js
CHANGED
|
@@ -1,34 +1,42 @@
|
|
|
1
1
|
// A scan: every country × protocol pair, tried in order, with pause and stop.
|
|
2
|
+
// Each slot runs one attempt at a time: one slot here, or one per docker container in a parallel scan.
|
|
2
3
|
import { probe } from "./probe.js";
|
|
3
4
|
import { now } from "./time.js";
|
|
4
5
|
// ponytail: rough per-attempt cost: a failure burns the timeout, a success ~connect+speed test; refine from real runs
|
|
5
6
|
export const attemptSeconds = (timeout) => timeout + 5;
|
|
6
|
-
export const estimate = (
|
|
7
|
+
export const estimate = (attempts, timeout, slots = 1) => Math.ceil(attempts / slots) * attemptSeconds(timeout);
|
|
7
8
|
/** Progress views read the fields; their keys call togglePause() and stop() */
|
|
8
9
|
export class Scan {
|
|
9
10
|
countries;
|
|
10
11
|
protocols;
|
|
11
12
|
options;
|
|
13
|
+
probers;
|
|
14
|
+
after;
|
|
12
15
|
results = [];
|
|
13
16
|
finished = []; // countries with every attempt done: the views refresh their tables per country
|
|
17
|
+
errors = []; // why slots stopped early
|
|
18
|
+
slots;
|
|
14
19
|
total;
|
|
15
20
|
started = now();
|
|
16
21
|
done = 0;
|
|
17
|
-
|
|
18
|
-
current = null;
|
|
19
|
-
step = ""; // what the running attempt is doing
|
|
20
|
-
stopping = false; // stop after the current attempt
|
|
22
|
+
stopping = false; // stop after the current attempts
|
|
21
23
|
// a pause lands between attempts, so the VPN is disconnected while waiting
|
|
22
24
|
paused = false;
|
|
23
|
-
waiting = false;
|
|
25
|
+
waiting = false; // paused, and no attempt running
|
|
24
26
|
waitStart = 0;
|
|
25
27
|
idle = 0; // seconds spent paused
|
|
26
28
|
resume = () => { };
|
|
27
|
-
|
|
29
|
+
unpaused = Promise.resolve();
|
|
30
|
+
constructor(countries, protocols, options, probers = [probe], after) {
|
|
28
31
|
this.countries = countries;
|
|
29
32
|
this.protocols = protocols;
|
|
30
33
|
this.options = options;
|
|
34
|
+
this.probers = probers;
|
|
35
|
+
this.after = after;
|
|
31
36
|
this.total = countries.length * protocols.length;
|
|
37
|
+
this.slots = probers.map(() => ({ current: null, step: "", attemptStart: now() }));
|
|
38
|
+
if (after)
|
|
39
|
+
this.slots.push({ current: null, step: "", attemptStart: now(), name: after.name });
|
|
32
40
|
}
|
|
33
41
|
/** Seconds spent scanning, pauses not counted */
|
|
34
42
|
elapsed() {
|
|
@@ -36,40 +44,71 @@ export class Scan {
|
|
|
36
44
|
}
|
|
37
45
|
togglePause() {
|
|
38
46
|
this.paused = !this.paused;
|
|
39
|
-
if (
|
|
40
|
-
this.
|
|
47
|
+
if (this.paused)
|
|
48
|
+
this.unpaused = new Promise((resolve) => (this.resume = resolve));
|
|
49
|
+
else
|
|
50
|
+
this.wake();
|
|
41
51
|
}
|
|
42
52
|
stop() {
|
|
43
53
|
this.stopping = true;
|
|
44
|
-
this.
|
|
54
|
+
this.wake(); // a paused scan stops right away
|
|
55
|
+
}
|
|
56
|
+
wake() {
|
|
57
|
+
if (this.waiting)
|
|
58
|
+
this.idle += now() - this.waitStart;
|
|
59
|
+
this.waiting = false;
|
|
60
|
+
this.resume();
|
|
45
61
|
}
|
|
46
62
|
async run(hooks = {}) {
|
|
63
|
+
const queue = this.countries.flatMap((country) => this.protocols.map((protocol) => ({ country, protocol })));
|
|
64
|
+
const left = new Map(this.countries.map((c) => [c, this.protocols.length])); // attempts to go, per country
|
|
65
|
+
const later = this.after?.protocols ?? new Set();
|
|
66
|
+
await Promise.all(this.probers.map((prober, i) => this.work(prober, this.slots[i], (p) => !later.has(p), queue, left, hooks)));
|
|
67
|
+
// then the rest, also what slots whose container stopped left over
|
|
68
|
+
if (this.after)
|
|
69
|
+
await this.work(this.after.prober, this.slots[this.probers.length], () => true, queue, left, hooks);
|
|
70
|
+
return this.results;
|
|
71
|
+
}
|
|
72
|
+
/** One slot: takes the attempts it can make from the queue, one at a time, until there are none */
|
|
73
|
+
async work(prober, slot, can, queue, left, hooks) {
|
|
47
74
|
const { timeout, speedTest } = this.options;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (this.
|
|
75
|
+
for (;;) {
|
|
76
|
+
while (this.paused && !this.stopping) {
|
|
77
|
+
if (!this.waiting && this.slots.every((s) => !s.current)) { // the last running attempt is over
|
|
51
78
|
this.waiting = true;
|
|
52
79
|
this.waitStart = now();
|
|
53
|
-
await new Promise((resolve) => (this.resume = resolve));
|
|
54
|
-
this.waiting = false;
|
|
55
|
-
this.idle += now() - this.waitStart;
|
|
56
|
-
}
|
|
57
|
-
if (this.stopping)
|
|
58
|
-
break outer;
|
|
59
|
-
this.current = { country, protocol };
|
|
60
|
-
this.attemptStart = now();
|
|
61
|
-
hooks.attempt?.(country, protocol);
|
|
62
|
-
const r = await probe(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (this.step = s) });
|
|
63
|
-
this.results.push(r);
|
|
64
|
-
this.done++;
|
|
65
|
-
hooks.result?.(r);
|
|
66
|
-
if (r.status === "no such location") { // its other protocols won't connect either
|
|
67
|
-
this.done += this.protocols.length - this.protocols.indexOf(protocol) - 1;
|
|
68
|
-
break;
|
|
69
80
|
}
|
|
81
|
+
await this.unpaused;
|
|
70
82
|
}
|
|
71
|
-
this.
|
|
83
|
+
const i = this.stopping ? -1 : queue.findIndex((a) => can(a.protocol));
|
|
84
|
+
if (i < 0)
|
|
85
|
+
return;
|
|
86
|
+
const [next] = queue.splice(i, 1);
|
|
87
|
+
const { country, protocol } = next;
|
|
88
|
+
Object.assign(slot, { current: next, step: "", attemptStart: now() });
|
|
89
|
+
hooks.attempt?.(country, protocol);
|
|
90
|
+
let r;
|
|
91
|
+
try {
|
|
92
|
+
r = await prober(country, protocol, { timeout, speedTest, stopped: () => this.stopping, onStep: (s) => (slot.step = s) });
|
|
93
|
+
}
|
|
94
|
+
catch (e) { // this slot is done for (its container stopped): the others take over its attempt
|
|
95
|
+
queue.unshift(next);
|
|
96
|
+
Object.assign(slot, { current: null, step: e.message });
|
|
97
|
+
this.errors.push(e.message);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
Object.assign(slot, { current: null, step: "" });
|
|
101
|
+
this.results.push(r);
|
|
102
|
+
let n = 1;
|
|
103
|
+
if (r.status === "no such location") // its other protocols won't connect either
|
|
104
|
+
for (let j = queue.length - 1; j >= 0; j--)
|
|
105
|
+
if (queue[j].country === country)
|
|
106
|
+
queue.splice(j, 1), n++;
|
|
107
|
+
this.done += n;
|
|
108
|
+
left.set(country, left.get(country) - n);
|
|
109
|
+
if (!left.get(country))
|
|
110
|
+
this.finished.push(country);
|
|
111
|
+
hooks.result?.(r);
|
|
72
112
|
}
|
|
73
|
-
return this.results;
|
|
74
113
|
}
|
|
75
114
|
}
|