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/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
- onStep(`measuring latency · ${r.location}`);
36
- r.latencyMs = await latencyMs();
37
- if (r.latencyMs === null)
38
- return end("no internet");
39
- if (speedTest) {
40
- onStep(`measuring speed · ${r.location}`);
41
- r.speedMbps = await speedMbps();
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 = (countries, protocols, timeout) => countries * protocols * attemptSeconds(timeout);
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
- attemptStart = now();
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
- constructor(countries, protocols, options) {
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 (!this.paused)
40
- this.resume();
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.resume(); // a paused scan stops right away
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
- outer: for (const country of this.countries) {
49
- for (const protocol of this.protocols) {
50
- if (this.paused && !this.stopping) {
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.finished.push(country);
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
  }