windscout 0.6.0 → 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 +3 -3
- package/dist/cli.js +3 -2
- package/dist/config.js +5 -21
- package/dist/docker.js +17 -36
- package/dist/tui/docker.js +37 -112
- package/dist/windscribe.js +48 -2
- package/dist/worker.js +23 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,9 +140,9 @@ The mirrors come from [Mirava](https://github.com/MiravaOrg/Mirava)'s list of mi
|
|
|
140
140
|
|
|
141
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
142
|
|
|
143
|
-
**Login:**
|
|
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
144
|
|
|
145
|
-
|
|
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
146
|
|
|
147
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
148
|
|
|
@@ -152,7 +152,7 @@ The mirrors come from [Mirava](https://github.com/MiravaOrg/Mirava)'s list of mi
|
|
|
152
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
153
|
- No firewall: "block internet while scanning" isn't used, since it would block the containers' traffic too.
|
|
154
154
|
|
|
155
|
-
The containers are removed after the scan. To remove the
|
|
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
156
|
|
|
157
157
|
## Output
|
|
158
158
|
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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, loadDockerWireGuard, MIN_TIMEOUT, saveDockerWireGuard } from "./config.js";
|
|
8
|
+
import { CONFIG, DEFAULT_TIMEOUT, forgetLogin, loadConfig, loadDockerWireGuard, MIN_TIMEOUT, saveDockerWireGuard } from "./config.js";
|
|
9
9
|
import { notInContainers, stopWorkers } from "./docker.js";
|
|
10
10
|
import { probe } from "./probe.js";
|
|
11
11
|
import { Scan } from "./scan.js";
|
|
@@ -96,8 +96,9 @@ async function main() {
|
|
|
96
96
|
});
|
|
97
97
|
if (a.worker) {
|
|
98
98
|
await worker();
|
|
99
|
-
process.exit(); // its stdin may still be open:
|
|
99
|
+
process.exit(); // its stdin may still be open: its app didn't log in
|
|
100
100
|
}
|
|
101
|
+
forgetLogin();
|
|
101
102
|
if (a.help)
|
|
102
103
|
return console.log(HELP);
|
|
103
104
|
checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The picker's selection, and the
|
|
1
|
+
// The picker's selection, and what the last parallel scan found its containers can do, remembered between runs.
|
|
2
2
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
@@ -6,8 +6,6 @@ import { COUNTRIES, PROTOCOLS } from "./windscribe.js";
|
|
|
6
6
|
export const DEFAULT_TIMEOUT = 15;
|
|
7
7
|
export const MIN_TIMEOUT = 10; // seconds; shorter cuts off connections that would have worked
|
|
8
8
|
export const CONFIG = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "windscout", "config.json");
|
|
9
|
-
// ponytail: plain text that only this user can read, like git's credential store; the OS keychain if that's not enough
|
|
10
|
-
export const LOGIN_FILE = join(dirname(CONFIG), "login.json");
|
|
11
9
|
const DOCKER_FILE = join(dirname(CONFIG), "docker.json"); // what the last parallel scan found its containers can do
|
|
12
10
|
/** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
|
|
13
11
|
export function loadConfig() {
|
|
@@ -50,24 +48,10 @@ export function saveDockerWireGuard(wireguard) {
|
|
|
50
48
|
}
|
|
51
49
|
catch { } // not being able to remember isn't worth failing the scan
|
|
52
50
|
}
|
|
53
|
-
/**
|
|
54
|
-
export function
|
|
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() {
|
|
55
53
|
try {
|
|
56
|
-
|
|
57
|
-
return typeof username === "string" && typeof password === "string" ? { username, password } : null;
|
|
54
|
+
rmSync(join(dirname(CONFIG), "login.json"), { force: true });
|
|
58
55
|
}
|
|
59
|
-
catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
/** Remembers the login, or forgets it (null) */
|
|
64
|
-
export function saveLogin(c) {
|
|
65
|
-
try {
|
|
66
|
-
rmSync(LOGIN_FILE, { force: true }); // a new file gets the mode below; an old one would keep its own
|
|
67
|
-
if (!c)
|
|
68
|
-
return;
|
|
69
|
-
mkdirSync(dirname(LOGIN_FILE), { recursive: true });
|
|
70
|
-
writeFileSync(LOGIN_FILE, JSON.stringify(c) + "\n", { mode: 0o600 });
|
|
71
|
-
}
|
|
72
|
-
catch { } // not being able to remember isn't worth failing the scan
|
|
56
|
+
catch { } // not being able to delete it isn't worth failing the scan
|
|
73
57
|
}
|
package/dist/docker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
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.
|
|
2
3
|
// A container runs `windscout --worker` (worker.ts) and takes attempts from here one at a time:
|
|
3
4
|
// a JSON object per line on its stdin, answered the same way on its stdout.
|
|
4
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -29,7 +30,7 @@ const REGISTRIES = [...(process.env.WINDSCOUT_REGISTRY_MIRRORS?.split(",").map((
|
|
|
29
30
|
// The CMD first checks the kernel (WSL's builds tun and parts of nftables as modules): no tun or nftables, no tunnel, so
|
|
30
31
|
// the container stops saying so instead of scanning. The helper's WireGuard routing also puts a fib rule in an inet table,
|
|
31
32
|
// 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)
|
|
33
|
+
// No systemd in a container: start the helper (root) like its unit would; the worker starts the app once it has the session
|
|
33
34
|
const DOCKERFILE = `FROM ${BASE_TAG}
|
|
34
35
|
ENV LANG=C.UTF-8
|
|
35
36
|
RUN for m in deb.debian.org mirror.shatel.ir mirror.iranserver.com mirror.mobinhost.com mirror2.chabokan.net; do \\
|
|
@@ -51,7 +52,6 @@ CMD mkdir -p /dev/net && { [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200; }
|
|
|
51
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 \\
|
|
52
53
|
&& wg=1 || wg=0; \\
|
|
53
54
|
/opt/windscribe/helper >/dev/null 2>&1 & \\
|
|
54
|
-
runuser -u ws -- env HOME=/home/ws /opt/windscribe/Windscribe >/dev/null 2>&1 & \\
|
|
55
55
|
exec runuser -u ws -- env HOME=/home/ws WS_WIREGUARD=$wg node /app/dist/cli.js --worker
|
|
56
56
|
`;
|
|
57
57
|
const user = userInfo().username;
|
|
@@ -105,7 +105,7 @@ export async function dockerState() {
|
|
|
105
105
|
return "ready";
|
|
106
106
|
}
|
|
107
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
|
|
108
|
+
const MAX_WORKERS = 8; // ponytail: past this, more containers mostly add load on the one uplink; raise if real scans say so
|
|
109
109
|
/** Protocols the containers can't do, which a parallel scan runs on this computer after them: IKEv2 (Windscribe's Linux app
|
|
110
110
|
* has none), and WireGuard when their kernel can't run its routing (see DOCKERFILE) */
|
|
111
111
|
export const notInContainers = (protocols, wireguard) => new Set(protocols.filter((p) => p === "ikev2" || (p === "wireguard" && !wireguard)));
|
|
@@ -243,27 +243,22 @@ const running = new Map(); // container name → its `docker run`, until the con
|
|
|
243
243
|
// a crash or a force quit leaves them running: remove them
|
|
244
244
|
const removeAll = () => running.size && spawnSync("docker", ["rm", "-f", ...running.keys()], { stdio: "ignore" });
|
|
245
245
|
process.on("exit", removeAll);
|
|
246
|
-
/** Starts n containers
|
|
247
|
-
* and whether WireGuard works in them */
|
|
248
|
-
export async function startWorkers(n,
|
|
249
|
-
const measuring = mutex()
|
|
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
250
|
let wireguard = true;
|
|
251
|
-
let creds; // undefined: not asked yet; null: the user cancelled
|
|
252
251
|
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
252
|
const start = (i) => new Promise((resolve) => {
|
|
259
253
|
const name = `windscout-${process.pid}-${i + 1}`;
|
|
260
|
-
// its own volume keeps its
|
|
254
|
+
// its own volume keeps its app's settings for the next scans: its WireGuard keys, registered with Windscribe once
|
|
261
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
|
|
262
256
|
const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name, "--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
|
|
263
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)
|
|
264
258
|
running.set(name, p);
|
|
265
259
|
const send = (m) => p.stdin.write(JSON.stringify(m) + "\n");
|
|
266
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
|
|
267
262
|
let on = (_) => { }; // the current phase's message handler
|
|
268
263
|
let fail = (_) => { }; // and what it does when the container stops
|
|
269
264
|
let dead = "", err = "";
|
|
@@ -312,28 +307,14 @@ export async function startWorkers(n, prompt, onStatus) {
|
|
|
312
307
|
};
|
|
313
308
|
send({ probe: { query, protocol, timeout, speedTest } });
|
|
314
309
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
}
|
|
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);
|
|
337
318
|
};
|
|
338
319
|
});
|
|
339
320
|
const probers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((p) => p !== null);
|
package/dist/tui/docker.js
CHANGED
|
@@ -1,33 +1,17 @@
|
|
|
1
|
-
// Docker on screen, for parallel scans: dialogs to install or start it (taking the sudo password),
|
|
2
|
-
// progress while the image builds and the containers start, and the Windscribe login the containers need.
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { LOGIN_FILE, loadLogin, saveLogin } from "../config.js";
|
|
5
1
|
import { buildImage, dockerState, INSTALL_DOCS, runSteps, startWorkers, steps, sudoLogin, sudoReady, waitReady, workerCount } from "../docker.js";
|
|
6
2
|
import { duration, now } from "../time.js";
|
|
3
|
+
import { session } from "../windscribe.js";
|
|
7
4
|
import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
|
|
8
|
-
import { bold, cyan, dim,
|
|
5
|
+
import { bold, cyan, dim, vw, yellow } from "./text.js";
|
|
9
6
|
import { box, centerIn } from "./widgets.js";
|
|
10
7
|
const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
|
|
11
8
|
const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
12
9
|
// lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
|
|
13
10
|
const wrap = (s, n = 76) => s.match(new RegExp(`.{1,${n}}(\\s+|$)|\\S+`, "g"))?.map((l) => l.trim()) ?? [s];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const body = [...lines], items = [];
|
|
19
|
-
if (fields.length || check)
|
|
20
|
-
body.push("");
|
|
21
|
-
fields.forEach((f, i) => {
|
|
22
|
-
items.push(body.length);
|
|
23
|
-
body.push(`${mark(i)}${f.label} ${bold(f.secret ? "•".repeat(a.values[i].length) : a.values[i])}${i === a.focus ? cyan("▏") : ""}`);
|
|
24
|
-
});
|
|
25
|
-
if (check) {
|
|
26
|
-
items.push(body.length);
|
|
27
|
-
body.push(`${mark(fields.length)}${a.checked ? green("[✓]") : dim("[ ]")} ${check.label}`);
|
|
28
|
-
if (check.note)
|
|
29
|
-
body.push(dim(`${many ? " " : ""} ${check.note}`));
|
|
30
|
-
}
|
|
11
|
+
function paint({ lines, password, buttons, error }, typed = "") {
|
|
12
|
+
const body = [...lines];
|
|
13
|
+
if (password !== undefined)
|
|
14
|
+
body.push("", `${password} ${bold("•".repeat(typed.length))}${cyan("▏")}`);
|
|
31
15
|
if (error)
|
|
32
16
|
body.push("", yellow(error));
|
|
33
17
|
const width = Math.max(56, ...body.map(vw));
|
|
@@ -36,86 +20,53 @@ function paint({ lines, fields = [], check, buttons, error }, a = { values: [],
|
|
|
36
20
|
const [ok, cancel] = buttons;
|
|
37
21
|
line = `${bold(cyan(`[ ${ok} ]`))}${cancel ? dim(` [ ${cancel} ]`) : ""}`;
|
|
38
22
|
row = body.length + 1;
|
|
39
|
-
body.push("", centerIn(line, width), centerIn(dim([
|
|
40
|
-
cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
23
|
+
body.push("", centerIn(line, width), centerIn(dim([`enter ${ok.toLowerCase()}`, cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
41
24
|
}
|
|
42
25
|
enterScreen();
|
|
43
26
|
const at = draw(box(body, { title: bold(cyan("WindScout · docker")), width }));
|
|
44
|
-
// a body line sits a row under the box's top border, two columns right of its left one
|
|
45
|
-
const rows = items.map((i) => at.top + 1 + i);
|
|
46
27
|
if (!buttons)
|
|
47
|
-
return {
|
|
28
|
+
return { row: -1, ok: [0, 0], cancel: [0, 0] };
|
|
29
|
+
// a body line sits a row under the box's top border, two columns right of its left one
|
|
48
30
|
const x = at.left + 2 + Math.floor((width - vw(line)) / 2), okEnd = x + vw(`[ ${buttons[0]} ]`);
|
|
49
|
-
return {
|
|
31
|
+
return { row: at.top + 1 + row, ok: [x, okEnd], cancel: buttons[1] ? [okEnd + 2, okEnd + 2 + vw(`[ ${buttons[1]} ]`)] : [0, 0] };
|
|
50
32
|
}
|
|
51
|
-
/** Shows the dialog until the user answers: what was typed
|
|
52
|
-
|
|
53
|
-
function form(d) {
|
|
33
|
+
/** Shows the dialog until the user answers: what was typed in its field ("" without one) on enter or a click on ok, null on esc */
|
|
34
|
+
function ask(d) {
|
|
54
35
|
if (!tty())
|
|
55
36
|
return Promise.resolve(null);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const a = { values: fields.map((f) => f.value ?? ""), checked: d.check?.on ?? false, focus: 0 };
|
|
60
|
-
let hits = paint(d, a);
|
|
61
|
-
const redraw = () => (hits = paint(d, a));
|
|
37
|
+
let typed = "";
|
|
38
|
+
let hits = paint(d, typed);
|
|
39
|
+
const redraw = () => (hits = paint(d, typed));
|
|
62
40
|
return new Promise((resolve) => {
|
|
63
41
|
const done = (v) => {
|
|
64
|
-
asking = false;
|
|
65
42
|
input.off("keypress", onKey);
|
|
66
43
|
input.off("mouse", onMouse);
|
|
67
44
|
out.off("resize", redraw);
|
|
68
45
|
resolve(v);
|
|
69
46
|
};
|
|
70
47
|
const onKey = (str, key = {}) => {
|
|
71
|
-
const field = a.focus < fields.length;
|
|
72
48
|
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
73
49
|
return done(null);
|
|
74
|
-
if (key.name === "return")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
a.focus = (a.focus + (key.shift ? n - 1 : 1)) % n;
|
|
81
|
-
else if (n > 1 && key.name === "up")
|
|
82
|
-
a.focus = (a.focus + n - 1) % n;
|
|
83
|
-
else if (!field) {
|
|
84
|
-
if (key.name === "space")
|
|
85
|
-
a.checked = !a.checked;
|
|
86
|
-
}
|
|
87
|
-
else if (key.name === "backspace")
|
|
88
|
-
a.values[a.focus] = a.values[a.focus].slice(0, -1);
|
|
89
|
-
else if (str && !key.ctrl && !key.meta && str >= " ")
|
|
90
|
-
a.values[a.focus] += str;
|
|
50
|
+
if (key.name === "return")
|
|
51
|
+
return done(typed);
|
|
52
|
+
if (key.name === "backspace")
|
|
53
|
+
typed = typed.slice(0, -1);
|
|
54
|
+
else if (d.password !== undefined && str && !key.ctrl && !key.meta && str >= " ")
|
|
55
|
+
typed += str;
|
|
91
56
|
redraw();
|
|
92
57
|
};
|
|
93
58
|
const onMouse = (m) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const on = ([from, to]) => m.y === hits.buttons && m.x >= from && m.x < to;
|
|
98
|
-
if (i >= 0) {
|
|
99
|
-
a.focus = i;
|
|
100
|
-
if (i === fields.length)
|
|
101
|
-
a.checked = !a.checked; // the checkbox
|
|
102
|
-
}
|
|
103
|
-
else if (on(hits.ok))
|
|
104
|
-
return done(a);
|
|
59
|
+
const on = ([from, to]) => m.press && m.button === 0 && m.y === hits.row && m.x >= from && m.x < to;
|
|
60
|
+
if (on(hits.ok))
|
|
61
|
+
done(typed);
|
|
105
62
|
else if (on(hits.cancel))
|
|
106
|
-
|
|
107
|
-
redraw();
|
|
63
|
+
done(null);
|
|
108
64
|
};
|
|
109
65
|
input.on("keypress", onKey);
|
|
110
66
|
input.on("mouse", onMouse);
|
|
111
67
|
out.on("resize", redraw);
|
|
112
68
|
});
|
|
113
69
|
}
|
|
114
|
-
/** A dialog with one field or none: the field's text ("" without one) on enter, null on esc */
|
|
115
|
-
const ask = async (d) => {
|
|
116
|
-
const a = await form(d);
|
|
117
|
-
return a && (a.values[0] ?? "");
|
|
118
|
-
};
|
|
119
70
|
/** Waits for work while the screen shows a spinner, status.label, the time so far and status.line (its latest output). esc quits */
|
|
120
71
|
async function busy(status, work) {
|
|
121
72
|
if (!tty()) {
|
|
@@ -124,10 +75,10 @@ async function busy(status, work) {
|
|
|
124
75
|
}
|
|
125
76
|
const t = now();
|
|
126
77
|
let frame = 0;
|
|
127
|
-
const tick = () =>
|
|
78
|
+
const tick = () => paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
|
|
128
79
|
dim(clip(status.line)), "", dim("esc quits")] });
|
|
129
|
-
//
|
|
130
|
-
const quit = (_, key = {}) =>
|
|
80
|
+
// the exit handlers remove containers already started
|
|
81
|
+
const quit = (_, key = {}) => (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
|
|
131
82
|
&& (leaveScreen(), process.exit(130));
|
|
132
83
|
const timer = setInterval(tick, 120);
|
|
133
84
|
input.on("keypress", quit);
|
|
@@ -160,7 +111,7 @@ export async function setupDocker() {
|
|
|
160
111
|
: [])];
|
|
161
112
|
let password, error = "";
|
|
162
113
|
for (;;) {
|
|
163
|
-
const a = await ask({ lines,
|
|
114
|
+
const a = await ask({ lines, password: root ? "password for sudo:" : undefined, buttons: [state === "missing" ? "Install" : "Start", "Cancel"], error });
|
|
164
115
|
if (a === null)
|
|
165
116
|
return false;
|
|
166
117
|
if (!root)
|
|
@@ -193,49 +144,23 @@ export async function setupDocker() {
|
|
|
193
144
|
return a !== null && setupDocker();
|
|
194
145
|
}
|
|
195
146
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"the login stays in the container's docker volume for the next scans.",
|
|
199
|
-
];
|
|
200
|
-
let lastUser = "", remember = true; // the box as last ticked: ticked to start with
|
|
201
|
-
/** A remembered login unless that just failed, else the login form */
|
|
202
|
-
async function credentials(error) {
|
|
203
|
-
const saved = loadLogin();
|
|
204
|
-
if (saved && !error)
|
|
205
|
-
return saved;
|
|
206
|
-
const a = await form({
|
|
207
|
-
lines: LOGIN,
|
|
208
|
-
fields: [{ label: "Windscribe username:", value: lastUser || saved?.username }, { label: "Windscribe password:", secret: true }],
|
|
209
|
-
check: { label: "remember username and password", on: remember,
|
|
210
|
-
note: `saved in ${LOGIN_FILE.replace(homedir(), "~")}, only you can read it` },
|
|
211
|
-
buttons: ["Log in", "Cancel"],
|
|
212
|
-
error,
|
|
213
|
-
});
|
|
214
|
-
if (!a)
|
|
215
|
-
return null;
|
|
216
|
-
remember = a.checked;
|
|
217
|
-
if (!remember)
|
|
218
|
-
saveLogin(null); // unticked: forget the one saved before, if any
|
|
219
|
-
const [username, password] = a.values;
|
|
220
|
-
return { username: (lastUser = username), password };
|
|
221
|
-
}
|
|
222
|
-
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the logins.
|
|
223
|
-
* Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
147
|
+
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the containers' apps logged in with
|
|
148
|
+
* this computer's session. Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
224
149
|
export async function startParallel(attempts) {
|
|
150
|
+
const s = session();
|
|
151
|
+
if (!s)
|
|
152
|
+
throw new Error("can't find the session this computer's Windscribe app is logged in with, which the containers' apps log in with:"
|
|
153
|
+
+ " update the Windscribe app, or scan without --docker (d in the picker)");
|
|
225
154
|
if (!(await setupDocker()))
|
|
226
155
|
return null;
|
|
227
156
|
const status = { label: "building the containers' image (the first time takes a few minutes)", line: "" };
|
|
228
157
|
await busy(status, buildImage((line) => (status.line = line)));
|
|
229
158
|
const n = workerCount(attempts);
|
|
230
159
|
Object.assign(status, { label: `starting ${n} container${n > 1 ? "s" : ""}`, line: "" });
|
|
231
|
-
const started = await busy(status, startWorkers(n,
|
|
232
|
-
credentials,
|
|
233
|
-
answer: (question) => ask({ lines: ["Windscribe asks:", "", ...question.split("\n")], fields: [{ label: "answer:" }], buttons: ["Send", "Cancel"] }),
|
|
234
|
-
loggedIn: (c) => remember && saveLogin(c), // saved only once it worked
|
|
235
|
-
}, (line) => (status.line = line)));
|
|
160
|
+
const started = await busy(status, startWorkers(n, s, (line) => (status.line = line)));
|
|
236
161
|
if (!started.probers.length) {
|
|
237
162
|
const why = status.line.startsWith("container") ? ` (${status.line})` : ""; // the last container's trouble
|
|
238
|
-
throw new Error(
|
|
163
|
+
throw new Error(`no container got ready${why}`);
|
|
239
164
|
}
|
|
240
165
|
return started;
|
|
241
166
|
}
|
package/dist/windscribe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Windscribe desktop app, driven through the windscribe-cli it ships with.
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { sleep } from "./time.js";
|
|
6
7
|
export const CLI = "windscribe-cli";
|
|
@@ -69,6 +70,51 @@ export async function waitForLogin() {
|
|
|
69
70
|
+ `\nDon't have it? ${DOWNLOAD_URL}`);
|
|
70
71
|
return status;
|
|
71
72
|
}
|
|
73
|
+
// The app's settings (Qt's, as Windscribe/Windscribe2): this INI file on Linux, the user's defaults on macOS, the registry on Windows
|
|
74
|
+
export const SETTINGS = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "Windscribe", "Windscribe2.conf");
|
|
75
|
+
/** A top-level value in the text of the INI file: Qt puts those under [General] */
|
|
76
|
+
export function iniValue(ini, key) {
|
|
77
|
+
let general = false;
|
|
78
|
+
for (const line of ini.split(/\r?\n/)) {
|
|
79
|
+
if (line.startsWith("["))
|
|
80
|
+
general = line.trim() === "[General]";
|
|
81
|
+
else if (general && line.startsWith(`${key}=`))
|
|
82
|
+
return line.slice(key.length + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** The INI file's text with the app logging in with this session when it starts. Settings another account left start over:
|
|
86
|
+
* the WireGuard keys in them are registered with that account */
|
|
87
|
+
export function withSession(ini, { userId, authHash }) {
|
|
88
|
+
if (userId && iniValue(ini, "userId") !== userId)
|
|
89
|
+
ini = "";
|
|
90
|
+
let general = false;
|
|
91
|
+
const lines = ini.split("\n").filter((line) => {
|
|
92
|
+
if (line.startsWith("["))
|
|
93
|
+
general = line.trim() === "[General]";
|
|
94
|
+
return !(general && line.startsWith("authHash="));
|
|
95
|
+
});
|
|
96
|
+
const at = lines.findIndex((line) => line.trim() === "[General]");
|
|
97
|
+
lines.splice(at + 1, 0, ...(at < 0 ? ["[General]"] : []), `authHash=${authHash}`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/** One of the app's top-level settings on this computer; "" when it isn't there, or doesn't look like an ID or a token */
|
|
101
|
+
function setting(key) {
|
|
102
|
+
const run = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
|
|
103
|
+
try {
|
|
104
|
+
const value = process.platform === "darwin" ? run("defaults", ["read", "com.windscribe.Windscribe2", key])
|
|
105
|
+
: process.platform === "win32" ? run("reg", ["query", "HKCU\\Software\\Windscribe\\Windscribe2", "/v", key]).match(/REG_SZ\s+(.*)/)?.[1]
|
|
106
|
+
: iniValue(readFileSync(SETTINGS, "utf8"), key);
|
|
107
|
+
return value?.trim().match(/^[\w:.+/=-]+$/)?.[0] ?? "";
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return ""; // no such setting, or no settings
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** The session this computer's app is logged in with; null when its settings don't have one */
|
|
114
|
+
export function session() {
|
|
115
|
+
const authHash = setting("authHash");
|
|
116
|
+
return authHash ? { userId: setting("userId"), authHash } : null;
|
|
117
|
+
}
|
|
72
118
|
export const isFirewallOn = (status) => /^Firewall state: On/m.test(status);
|
|
73
119
|
let firewallChanged = false; // blockInternet() turned the firewall on, so restoreFirewall() turns it back off
|
|
74
120
|
/** Turns on the app's firewall, so nothing goes online outside the VPN. False if it was on already (it then stays on) */
|
package/dist/worker.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// windscout --worker: runs in a parallel scan's docker container (docker.ts), beside the container's own Windscribe app.
|
|
2
2
|
// Takes the host's messages from stdin and answers on stdout, a JSON object per line.
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
4
6
|
import { createInterface } from "node:readline";
|
|
5
7
|
import { probe } from "./probe.js";
|
|
6
|
-
import { sleep } from "./time.js";
|
|
7
|
-
import {
|
|
8
|
+
import { now, sleep } from "./time.js";
|
|
9
|
+
import { SETTINGS, windscribe, withSession } from "./windscribe.js";
|
|
10
|
+
const APP = "/opt/windscribe/Windscribe"; // as the image installs it
|
|
8
11
|
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
9
|
-
// the CLI mixes JSON log lines into its output
|
|
10
|
-
const text = (s) => s.split("\n").filter((l) => !l.startsWith('{"tm"')).join("\n").trim();
|
|
11
12
|
export async function worker() {
|
|
12
13
|
const inbox = [];
|
|
13
14
|
let wake = () => { }, closed = false, stop = false;
|
|
@@ -26,21 +27,27 @@ export async function worker() {
|
|
|
26
27
|
await new Promise((resolve) => (wake = resolve));
|
|
27
28
|
return inbox.shift() ?? null;
|
|
28
29
|
};
|
|
29
|
-
//
|
|
30
|
+
// The app logs in with this computer's session, which goes in its settings before it starts: no login of its own, since
|
|
31
|
+
// Windscribe locks an account that logs in many times at once. Never log out here: that would end this computer's session too
|
|
32
|
+
const session = (await next())?.session;
|
|
33
|
+
if (!session)
|
|
34
|
+
return;
|
|
35
|
+
let ini = "";
|
|
36
|
+
try {
|
|
37
|
+
ini = readFileSync(SETTINGS, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch { } // a new volume: no settings yet
|
|
40
|
+
mkdirSync(dirname(SETTINGS), { recursive: true });
|
|
41
|
+
writeFileSync(SETTINGS, withSession(ini, session));
|
|
42
|
+
// no systemd in a container: start the app like its unit would, as this user (its CLI refuses root)
|
|
43
|
+
spawn(APP, { stdio: "ignore" }).on("error", () => { }); // not there: its CLI never answers below
|
|
44
|
+
// its CLI answers once it's up, and says logged in once Windscribe accepts the session, or the error it got instead
|
|
30
45
|
let status = "";
|
|
31
|
-
for (
|
|
46
|
+
for (const t = now(); now() - t < 120 && !/Login state: (Logged in|Error)/.test(status = await windscribe("status"));)
|
|
32
47
|
await sleep(1000);
|
|
33
|
-
if (!status.includes("Login state"))
|
|
34
|
-
return void console.error("Windscribe's app didn't start in the container");
|
|
35
48
|
if (!status.includes("Logged in")) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const m = await next();
|
|
39
|
-
if (!m?.login)
|
|
40
|
-
return; // no credentials for us: this container sits out the scan
|
|
41
|
-
if (!(error = await login(m.login.username, m.login.password, next)))
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
49
|
+
const state = status.match(/^Login state: (.*)$/m)?.[1].trim();
|
|
50
|
+
return void console.error(state ? `Windscribe's app didn't log in with this computer's session (${state})` : "Windscribe's app didn't start in the container");
|
|
44
51
|
}
|
|
45
52
|
send({ ready: true, wireguard: process.env.WS_WIREGUARD !== "0" }); // the image's CMD checked the kernel
|
|
46
53
|
for (let m; (m = await next());) {
|
|
@@ -66,27 +73,3 @@ export async function worker() {
|
|
|
66
73
|
send({ result });
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
|
-
/** windscribe-cli login, with the password on its stdin; what it asks next (a 2FA code, a captcha) goes to the host.
|
|
70
|
-
* "" once logged in, else why not */
|
|
71
|
-
function login(username, password, next) {
|
|
72
|
-
return new Promise((resolve) => {
|
|
73
|
-
const p = spawn(CLI, ["login", username], { stdio: ["pipe", "pipe", "ignore"] });
|
|
74
|
-
p.stdin.on("error", () => { });
|
|
75
|
-
p.stdin.write(password + "\n");
|
|
76
|
-
let out = "";
|
|
77
|
-
p.stdout.on("data", async (d) => {
|
|
78
|
-
out += d;
|
|
79
|
-
const asked = text(out);
|
|
80
|
-
// a question ends the output, with no newline after it: "2FA code:", or under a captcha "…to continue:"
|
|
81
|
-
if (!asked.endsWith(":"))
|
|
82
|
-
return;
|
|
83
|
-
out = "";
|
|
84
|
-
if (asked.endsWith("Password:"))
|
|
85
|
-
return; // answered already
|
|
86
|
-
send({ need: "input", text: asked });
|
|
87
|
-
p.stdin.write(((await next())?.input ?? "") + "\n");
|
|
88
|
-
});
|
|
89
|
-
// it exits 0 whether that worked or not: ask the app
|
|
90
|
-
p.on("close", async () => resolve((await windscribe("status")).includes("Logged in") ? "" : text(out).split("\n").at(-1) || "login failed"));
|
|
91
|
-
});
|
|
92
|
-
}
|