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 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:** each container's Windscribe app logs in once. WindScout asks for your Windscribe username and password (and a 2FA code or captcha when Windscribe asks for one) and passes them to the containers, one at a time. Each login is kept in that container's docker volume (`windscout-1`, `windscout-2`, …), so later scans don't ask.
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
- **Remember username and password** is ticked to start with (tab to it and press space, or click it). Once the login works, WindScout saves both in `~/.config/windscout/login.json`, as plain text that only you can read. Then containers that are new next time (a scan with more attempts) log in by themselves, with no terminal too; a 2FA code is never saved, so with 2FA they still ask. Untick it and nothing is saved, and a login saved before is forgotten. Deleting that file forgets it too. With `-c` and no terminal, only containers that are logged in already, or can log in with the remembered login, take part.
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 logins too, run `docker volume rm windscout-1 windscout-2 …`. To remove the image, run `docker rmi windscout-worker`.
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: it has no login to scan with
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 docker containers' Windscribe login if wanted, remembered between runs.
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
- /** The Windscribe login remembered for the docker containers; null when there's none */
54
- export function loadLogin() {
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
- const { username, password } = JSON.parse(readFileSync(LOGIN_FILE, "utf8"));
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) and the app (as a user: its CLI refuses root) like their units would
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 logins and load on the one uplink; raise if real scans say so
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 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();
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 Windscribe login for the next scans
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
- 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
- }
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);
@@ -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, green, vw, yellow } from "./text.js";
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
- let asking = false; // a dialog waits for the user: progress doesn't draw over it
15
- function paint({ lines, fields = [], check, buttons, error }, a = { values: [], checked: false, focus: 0 }) {
16
- const many = fields.length + (check ? 1 : 0) > 1; // then a ❯ marks where the keys go
17
- const mark = (i) => (!many ? "" : i === a.focus ? cyan("❯ ") : " ");
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([many && "tab/↑↓ move", check && "space ticks the box", `enter ${ok.toLowerCase()}`,
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 { rows, buttons: -1, ok: [0, 0], cancel: [0, 0] };
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 { rows, buttons: at.top + 1 + row, ok: [x, okEnd], cancel: buttons[1] ? [okEnd + 2, okEnd + 2 + vw(`[ ${buttons[1]} ]`)] : [0, 0] };
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 and ticked on enter (or a click on ok), null on esc.
52
- * tab, ↑↓ and enter move between the fields; space or a click ticks the checkbox */
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
- asking = true;
57
- const fields = d.fields ?? [];
58
- const n = fields.length + (d.check ? 1 : 0);
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
- if (a.focus >= fields.length - 1)
76
- return done(a);
77
- a.focus++; // like a form: on to the next field
78
- }
79
- else if (n > 1 && (key.name === "tab" || key.name === "down"))
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
- if (!m.press || m.button !== 0)
95
- return;
96
- const i = hits.rows.indexOf(m.y);
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
- return done(null);
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 = () => asking || paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
78
+ const tick = () => paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
128
79
  dim(clip(status.line)), "", dim("esc quits")] });
129
- // a dialog on top (the login) takes its own keys; the exit handlers remove containers already started
130
- const quit = (_, key = {}) => !asking && (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
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, fields: root ? [{ label: "password for sudo:", secret: true }] : [], buttons: [state === "missing" ? "Install" : "Start", "Cancel"], error });
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
- const LOGIN = [
197
- "Each container runs its own Windscribe app, which logs in once:",
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(why || tty() ? `no container got ready${why}` : "the containers aren't logged in to Windscribe yet: run windscout --docker in a terminal once");
163
+ throw new Error(`no container got ready${why}`);
239
164
  }
240
165
  return started;
241
166
  }
@@ -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 { CLI, windscribe } from "./windscribe.js";
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
- // the app starts beside us (the image's CMD); its CLI answers once it's up
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 (let i = 0; i < 60 && !(status = await windscribe("status")).includes("Login state"); i++)
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
- for (let error = "";;) {
37
- send({ need: "login", text: error });
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windscout",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
5
5
  "type": "module",
6
6
  "bin": {