windscout 0.6.0 → 0.6.4

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
@@ -1,8 +1,8 @@
1
1
  # WindScout
2
2
 
3
- Find which Windscribe locations and protocols actually work from your network, ranked by speed. Runs on Linux, macOS and Windows.
3
+ Find which Windscribe locations and protocols actually work from your network, ranked by latency. Runs on Linux, macOS and Windows.
4
4
 
5
- WindScout drives the official `windscribe-cli`: it connects to each country × protocol pair, measures latency and download speed through the tunnel, disconnects, and prints a ranked table.
5
+ WindScout drives the official `windscribe-cli`: it connects to each country × protocol pair, measures latency through the tunnel, disconnects, and prints a ranked table.
6
6
 
7
7
  ![WindScout picking countries, scanning, and printing the availability grid and ranked results](demo.gif)
8
8
 
@@ -30,7 +30,7 @@ Run `windscout` with no arguments to open the interactive picker:
30
30
  ```
31
31
  ╭─ WindScout ──────────────────────────────────── 2/68 selected ─╮
32
32
  │ 1 ● wireguard 2 ● stealth 3 ○ wstunnel 4 ● udp 5 ● tcp │
33
- │ s ○ speed test off: connect-only, ranks by latency │
33
+ │ p ○ all ports off: each protocol on its default port │
34
34
  ╰────────────────────────────────────────────────────────────────╯
35
35
  ╭────────────────────────────────────────────────────────────────╮
36
36
  │ 🔍 an▏ 1–16 of 18 │
@@ -51,7 +51,7 @@ Run `windscout` with no arguments to open the interactive picker:
51
51
  | `/` | search by country name or code (enter/esc to finish, esc again to clear) |
52
52
  | `a` / `n` | select all / unselect all (only the matching countries while a search is active) |
53
53
  | `1`–`6` | toggle a protocol (`1`–`5` on Linux, which has no IKEv2) |
54
- | `s` | toggle the speed test (off = connect-only mode) |
54
+ | `p` | toggle [all ports](#all-ports): each protocol on every port it has, not just its default |
55
55
  | `f` | toggle "block internet while scanning" (firewall) |
56
56
  | `r` | toggle random order: scan the selected countries shuffled, a new order each run |
57
57
  | `d` | toggle the [parallel scan in docker containers](#parallel-scan-with-docker) (installs or starts docker first, after asking) |
@@ -59,11 +59,11 @@ Run `windscout` with no arguments to open the interactive picker:
59
59
  | `enter` | start scanning |
60
60
  | `q` | quit (during a scan: stop and show partial results; press again to force quit) |
61
61
 
62
- **Mouse:** click a country to select or unselect it, click a protocol or the speed-test line to toggle it, click the search box to start typing (click anywhere else to leave it), and scroll the list with the wheel. The `[ ▶ Start ] [ All ] [ None ] [ Quit ]` buttons under the table do the same as their keys; the shortcut key is underlined on screen (on the buttons, the protocol numbers and `s`). This needs a terminal with mouse support, which most have. On Windows, use Windows Terminal; if clicks do nothing there, the keyboard does everything.
62
+ **Mouse:** click a country to select or unselect it, click a protocol or a setting's line to toggle it, click the search box to start typing (click anywhere else to leave it), and scroll the list with the wheel. The `[ ▶ Start ] [ All ] [ None ] [ Quit ]` buttons under the table do the same as their keys; the shortcut key is underlined on screen (on the buttons, the protocol numbers and the settings' letters). This needs a terminal with mouse support, which most have. On Windows, use Windows Terminal; if clicks do nothing there, the keyboard does everything.
63
63
 
64
64
  Your selection is saved to `~/.config/windscout/config.json` (`%USERPROFILE%\.config\windscout\config.json` on Windows) and preselected next time.
65
65
 
66
- While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting, measuring latency or measuring speed). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command. At the prompt, press enter to connect to the best one, type another one's number to connect to that, or `q` to quit.
66
+ While scanning, WindScout shows a progress bar, elapsed time and remaining time. It also shows what it's doing right now (connecting or measuring latency). The table of working locations refreshes after each country. When the scan finishes, the final report is printed to your normal terminal. It ends with the best connection and the other working ones, each with its `windscribe-cli connect` command. At the prompt, press enter to connect to the best one, type another one's number to connect to that, or `q` to quit.
67
67
 
68
68
  ### Non-interactive
69
69
 
@@ -72,6 +72,7 @@ Pass `-c` to skip the picker, for scripts or when there is no terminal:
72
72
  ```sh
73
73
  windscout -c DE,NL,TR -p stealth,tcp:443 # chosen countries and protocols (port optional)
74
74
  windscout -c Frankfurt,Wurstchen # cities or datacenter nicknames work too
75
+ windscout -c DE -p stealth,tcp -P # every port of stealth and tcp
75
76
  windscout -t 40 -a # longer connect timeout, also list failed attempts
76
77
  ```
77
78
 
@@ -79,19 +80,17 @@ windscout -t 40 -a # longer connect timeout, also list
79
80
  |---|---|---|
80
81
  | `-c, --countries` | comma-separated ISO country codes, city names or nicknames | open the picker |
81
82
  | `-p, --protocols` | comma-separated: `wireguard`, `ikev2` (macOS and Windows), `stealth`, `wstunnel`, `udp`, `tcp`, optionally `:port` | saved, or all |
83
+ | `-P, --all-ports` | try each protocol on [every port](#all-ports) Windscribe offers for it, not just its default | picker: saved, or off; with `-c`: off |
82
84
  | `-t, --timeout` | seconds to wait for each connection, at least 10 | picker: saved, or 15; with `-c`: 15 |
83
85
  | `-a, --all` | include failed attempts in the final table | off |
84
- | `-n, --no-speed` | connect-only mode: skip the download test, rank by latency | picker: saved, or connect-only on first run; with `-c`: speed test on |
85
86
  | `-f, --firewall` | block internet while scanning, except through the VPN | picker: saved, or off; with `-c`: off |
86
87
  | `-r, --random` | scan the countries in a random order | picker: saved, or off; with `-c`: off |
87
88
  | `-d, --docker` | [scan in parallel, in docker containers](#parallel-scan-with-docker) | picker: saved, or off; with `-c`: off |
88
89
  | `-v, --version` | print the installed version; if a newer one is on npm, show the update command (with `sudo` when needed) and ask (y/n) to run it | |
89
90
 
90
- ## Connect-only mode
91
+ ## Availability grid
91
92
 
92
- Often you only need to know **what connects**, not how fast it is. Turn the speed test off with `s` in the picker or with `--no-speed`. Each working connection is then checked with a quick latency test only. That saves about 15 s per working connection, and results are ranked by latency. Connect-only is the default on the first run, and the choice is remembered.
93
-
94
- Both modes show an **availability grid**: one row per country, and ✓ / ✗ for each protocol (`·` = not tried yet). Location(s) lists each datacenter that connected, one per line, since protocols can land on different ones:
93
+ Each working connection gets a quick latency test, and the working ones are ranked by latency. The scan screen and the final report show an **availability grid**: one row per country, and ✓ / ✗ for each protocol (`·` = not tried yet; `key limit`, in a [parallel scan](#parallel-scan-with-docker): WireGuard couldn't be tried). With [all ports](#all-ports), it counts how many of a protocol's ports worked: `✓ 3/11`. Location(s) lists each datacenter that connected, one per line, since protocols can land on different ones:
95
94
 
96
95
  ```
97
96
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -106,7 +105,25 @@ Both modes show an **availability grid**: one row per country, and ✓ / ✗ for
106
105
  ╰─────────────────────────┴────────────────────────┴─────────┴───────────┴─────╯
107
106
  ```
108
107
 
109
- While scanning, press `tab` to switch between the availability grid and the speed or latency ranking. Press `p` (or `space`) to pause: the current attempt finishes, the VPN disconnects, and the scan waits until you press `p` again. Paused time doesn't count toward elapsed or remaining. In connect-only mode the scan starts on the availability grid.
108
+ While scanning, press `tab` to switch between the availability grid and the latency ranking. Press `p` (or `space`) to pause: the current attempt finishes, the VPN disconnects, and the scan waits until you press `p` again. Paused time doesn't count toward elapsed or remaining. The scan starts on the availability grid.
109
+
110
+ ## All ports
111
+
112
+ Each protocol is normally tried on its default port, the first one Windscribe lists for it. A network can block a protocol on one port and let it through on another, so turn on **all ports** with `p` in the picker or `-P` (`--all-ports`): each selected protocol is then tried on every port Windscribe offers for it. WindScout asks your Windscribe app for the ports (`windscribe-cli ports tcp`), so the list follows Windscribe's. In October 2026 it was:
113
+
114
+ | Protocol | Ports | Attempts per country |
115
+ |---|---|---|
116
+ | wireguard | 443, 80, 53, 123, 1194, 65142 | 6 |
117
+ | stealth | 443, 587, 21, 22, 80, 123, 143, 3306, 8080, 54783, 8443 | 11 |
118
+ | wstunnel | 443 | 1 |
119
+ | udp | 443, 80, 53, 123, 1194, 54783 | 6 |
120
+ | tcp | 443, 587, 21, 22, 80, 123, 143, 3306, 8080, 54783, 1194 | 11 |
121
+
122
+ With every protocol selected, that's 35 attempts per country instead of 5, 7 times as many (on macOS and Windows, IKEv2 adds its own ports). The picker shows the count (`35 attempts per country, not 5`) and the estimate: one country takes about 11m 40s instead of 1m 40s in a normal scan, and about 2 minutes in a [parallel scan](#parallel-scan-with-docker), which helps most here. To try fewer, select only the protocols you need, or give a port yourself: a protocol with a port (`-p tcp:443`) stays on that one.
123
+
124
+ The availability grid keeps one column per protocol and counts how many of its ports worked (`✓ 3/11`, or `✗ 0/11` when none did). The results list each working protocol with its port, and the per-protocol table has a line per port.
125
+
126
+ When the app making an attempt doesn't offer a port, Windscribe quietly connects on the protocol's default port instead. That can happen in a parallel scan, where each container runs its own copy of the app, which can be older or newer than yours. WindScout checks the port it actually connected on and shows those attempts as `no port` (in yellow), rather than as results for a port that wasn't tried. Listing ports needs Windscribe 2.22 or newer. With an older app, all ports changes nothing, and the picker says so.
110
127
 
111
128
  ## Block internet while scanning (firewall)
112
129
 
@@ -116,7 +133,7 @@ Between attempts the VPN is disconnected for a moment, and apps on your computer
116
133
 
117
134
  A normal scan tries one location at a time, because your computer has one VPN connection. Turn on **parallel scan** with `d` in the picker or `--docker`, and each attempt runs in its own docker container instead. Each container has its own Windscribe app (Windscribe's Linux CLI app) and so its own VPN connection, and several locations are tried at once. The results come back to the scan screen, which shows a line per container.
118
135
 
119
- **How many containers:** one per attempt, as many as docker's CPUs allow and three quarters of its memory (at 250 MB each), at most 8. The picker shows the number (`8 at a time`) and the estimate that goes with it.
136
+ **How many containers:** one per attempt, so 1 country with 5 protocols starts 5. Docker's CPUs, three quarters of its memory (at 250 MB each) and a maximum of 16 limit that. With more attempts than that, WindScout starts the fewest containers that need no more rounds: 17 attempts get 9 containers with 2 attempts each, the same 2 rounds that 16 containers would take. Fewer containers log in sooner (in a test, 1 took 37 s, and 16 together up to 75 s) and register fewer WireGuard keys. The picker shows the number (`9 at a time`) and the estimate that goes with it, which includes the time the containers take to log in before the scan starts (`+ ~58s login`: about 35 s, plus 2.5 s per container).
120
137
 
121
138
  **First time:** if docker isn't installed or isn't running, `d` (or a scan with `--docker`) opens a dialog that says what WindScout will do, and does it once you agree:
122
139
 
@@ -140,11 +157,13 @@ The mirrors come from [Mirava](https://github.com/MiravaOrg/Mirava)'s list of mi
140
157
 
141
158
  **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
159
 
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.
160
+ **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.
161
+
162
+ **WireGuard keys:** each container's app registers a WireGuard key of its own with your account the first time it uses WireGuard, and keeps it in its volume for later scans. Windscribe allows each account only so many keys. A container past that limit can't try WireGuard, which says nothing about your network, so the grid shows `key limit` (in yellow) instead of ✗, and those attempts don't count in the report's per-protocol numbers. Under the grid, the report says how many there were and gives the command that tries them on this computer, which has a key of its own: `windscout -c "DE,NL" -p wireguard`.
144
163
 
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.
164
+ 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
165
 
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.
166
+ **Measuring:** the containers share your internet connection, so they take turns measuring latency 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
167
 
149
168
  **Differences from a normal scan:**
150
169
 
@@ -152,7 +171,7 @@ The mirrors come from [Mirava](https://github.com/MiravaOrg/Mirava)'s list of mi
152
171
  - 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
172
  - No firewall: "block internet while scanning" isn't used, since it would block the containers' traffic too.
154
173
 
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`.
174
+ 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
175
 
157
176
  ## Output
158
177
 
@@ -164,23 +183,23 @@ While scanning:
164
183
  │ ██████████████████████████████████████████▌░░░░░░░░░░░░░░░░ 72% │
165
184
  │ │
166
185
  │ ⠏ 🇳🇱 Netherlands · stealth │
167
- │ measuring speed · Amsterdam - Tulip · 3s │
186
+ │ measuring latency · Amsterdam - Tulip · 1s │
168
187
  ├──────────────────────────────────────────────────────────────────────┤
169
188
  │ elapsed 25s │ remaining ≈ 13s │ ✓ 2 working │ ✗ 1 blocked │
170
189
  ╰──────────────────────────────────────────────────────────────────────╯
171
190
  ╭──────────────────────────────────────────────────────────────────────╮
172
191
  │ Working so far 2 found │
173
192
  ├───┬────────────┬───────────────────────┬───────────┬───────────┬─────┤
174
- │ # │ Country │ Location │ Protocol │ Speed │ ... │
193
+ │ # │ Country │ Location │ Protocol │ Latency │ ... │
175
194
  ```
176
195
 
177
196
  At the end, the results, a per-protocol summary and the best pick are printed to your normal terminal:
178
197
 
179
198
  ```
180
- ╭─ ★ Best ─────────────────────────────────────────────────╮
181
- │ 🇳🇱 Amsterdam - Tulip over wireguard · 27.0 Mbps · 109 ms │
182
- │ windscribe-cli connect "Tulip" wireguard │
183
- ╰──────────────────────────────────────────────────────────╯
199
+ ╭─ ★ Best (lowest latency) ────────────────────╮
200
+ │ 🇳🇱 Amsterdam - Tulip over wireguard · 109 ms │
201
+ │ windscribe-cli connect "Tulip" wireguard │
202
+ ╰──────────────────────────────────────────────╯
184
203
  ```
185
204
 
186
205
  ## Notes
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // WindScout: find which Windscribe locations and protocols actually work from here.
3
3
  // Drives the official windscribe-cli: connects to each country/protocol pair,
4
- // measures latency and download speed through the tunnel, prints a ranked table.
4
+ // measures latency through the tunnel, prints a ranked table.
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, SPEED_TEST } 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";
@@ -17,27 +17,28 @@ import { leaveScreen, out } from "./tui/terminal.js";
17
17
  import { cyan, green, yellow } from "./tui/text.js";
18
18
  import { now, sleep } from "./time.js";
19
19
  import { canUpdate, checkUpdate, runUpdate, updateNotice, versionLines } from "./update.js";
20
- import { blockInternet, CLI, connectState, isFirewallOn, PROTOCOLS, restoreFirewall, waitForLogin, windscribe } from "./windscribe.js";
20
+ import { blockInternet, CLI, connectState, isFirewallOn, protocolPorts, PROTOCOLS, restoreFirewall, waitForLogin, windscribe, withPorts } from "./windscribe.js";
21
21
  import { worker } from "./worker.js";
22
22
  const HELP = `Usage: windscout [options]
23
23
 
24
- Find which Windscribe locations and protocols work from your network, ranked by speed.
24
+ Find which Windscribe locations and protocols work from your network, ranked by ${SPEED_TEST ? "speed" : "latency"}.
25
25
  Without -c it opens an interactive country picker (your selection is remembered).
26
26
 
27
27
  Options:
28
28
  -c, --countries LIST comma-separated ISO codes, city names or nicknames (skips the picker)
29
29
  -p, --protocols LIST comma-separated protocols, optional :port (default: ${PROTOCOLS.join(",")})
30
+ -P, --all-ports try each protocol on every port Windscribe offers for it, not just its default
30
31
  -t, --timeout SEC seconds to wait for each connection, at least ${MIN_TIMEOUT} (default: saved, or ${DEFAULT_TIMEOUT})
31
32
  -a, --all also show failed attempts in the final table
32
- -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)
33
- -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
33
+ ${SPEED_TEST ? " -n, --no-speed connect-only: skip the download test, rank by latency (faster scan)\n" : ""} -f, --firewall block internet while scanning, except through the VPN (turned back off afterwards)
34
34
  -r, --random scan the countries in a random order
35
- -d, --docker scan in parallel, in docker containers (as many as docker's CPUs and memory allow, up to 8)
35
+ -d, --docker scan in parallel, in docker containers (one per attempt, up to 16, as docker's CPUs and memory allow)
36
36
  -v, --version show the installed version (and offer to update if a newer one is out)
37
37
  -h, --help show this help
38
38
 
39
39
  Selection is stored in ${CONFIG}
40
- Example: windscout -c DE,NL,TR -p stealth,tcp:443`;
40
+ Examples: windscout -c DE,NL,TR -p stealth,tcp:443
41
+ windscout -c DE -p stealth,tcp -P`;
41
42
  // "DE, NL,,TR" → ["DE", "NL", "TR"]
42
43
  const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
43
44
  const ask = (question) => new Promise((resolve) => {
@@ -83,11 +84,12 @@ async function main() {
83
84
  options: {
84
85
  countries: { type: "string", short: "c" },
85
86
  protocols: { type: "string", short: "p" },
87
+ "all-ports": { type: "boolean", short: "P", default: false },
86
88
  timeout: { type: "string", short: "t" },
87
89
  all: { type: "boolean", short: "a", default: false },
88
90
  help: { type: "boolean", short: "h", default: false },
89
91
  version: { type: "boolean", short: "v", default: false },
90
- "no-speed": { type: "boolean", short: "n", default: false },
92
+ "no-speed": { type: "boolean", short: "n", default: false }, // still accepted while the speed test is hidden
91
93
  firewall: { type: "boolean", short: "f", default: false },
92
94
  random: { type: "boolean", short: "r", default: false },
93
95
  docker: { type: "boolean", short: "d", default: false },
@@ -96,8 +98,9 @@ async function main() {
96
98
  });
97
99
  if (a.worker) {
98
100
  await worker();
99
- process.exit(); // its stdin may still be open: it has no login to scan with
101
+ process.exit(); // its stdin may still be open: its app didn't log in
100
102
  }
103
+ forgetLogin();
101
104
  if (a.help)
102
105
  return console.log(HELP);
103
106
  checkUpdate(); // runs while the app starts and the user picks, before the VPN starts switching
@@ -115,9 +118,10 @@ async function main() {
115
118
  const tty = Boolean(process.stdin.isTTY && out.isTTY);
116
119
  let countries = a.countries ? list(a.countries) : undefined;
117
120
  let protocols = a.protocols ? list(a.protocols) : undefined;
118
- let speedTest = !a["no-speed"];
121
+ let speedTest = SPEED_TEST && !a["no-speed"];
119
122
  let firewall = a.firewall;
120
123
  let random = a.random;
124
+ let allPorts = a["all-ports"];
121
125
  let docker = a.docker;
122
126
  if (!countries) {
123
127
  if (!tty)
@@ -131,6 +135,7 @@ async function main() {
131
135
  // already on in the app: show it on, since the scan won't turn it off
132
136
  firewall: firewall || isFirewallOn(status) || (saved?.firewall ?? false),
133
137
  random: random || (saved?.random ?? false),
138
+ allPorts: allPorts || (saved?.allPorts ?? false),
134
139
  docker: docker || (saved?.docker ?? false),
135
140
  });
136
141
  if (!choice)
@@ -142,11 +147,18 @@ async function main() {
142
147
  speedTest = choice.speed;
143
148
  firewall = choice.firewall;
144
149
  random = choice.random;
150
+ allPorts = choice.allPorts;
145
151
  timeout = choice.timeout;
146
152
  docker = choice.docker;
147
153
  }
148
154
  protocols ??= PROTOCOLS;
149
155
  timeout ??= DEFAULT_TIMEOUT;
156
+ // the picker asked for the ports already: waiting for that keeps windscribe-cli free for the scan
157
+ if (allPorts || !a.countries) {
158
+ const lists = await protocolPorts();
159
+ if (allPorts)
160
+ protocols = withPorts(protocols, lists);
161
+ }
150
162
  if (random)
151
163
  countries = countries.map((c) => [Math.random(), c]).sort(([x], [y]) => x - y).map(([, c]) => c);
152
164
  let probers = [probe], after; // after: what runs here once the containers are done
package/dist/config.js CHANGED
@@ -1,13 +1,13 @@
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";
5
5
  import { COUNTRIES, PROTOCOLS } from "./windscribe.js";
6
+ // The speed test is hidden: every scan is connect-only, and a saved "on" reads as off. Its code stays; true brings it back
7
+ export const SPEED_TEST = false;
6
8
  export const DEFAULT_TIMEOUT = 15;
7
9
  export const MIN_TIMEOUT = 10; // seconds; shorter cuts off connections that would have worked
8
10
  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
11
  const DOCKER_FILE = join(dirname(CONFIG), "docker.json"); // what the last parallel scan found its containers can do
12
12
  /** The saved choice, minus anything this version doesn't offer; null on first run or an unreadable file */
13
13
  export function loadConfig() {
@@ -16,9 +16,10 @@ export function loadConfig() {
16
16
  return {
17
17
  countries: (c.countries ?? []).filter((x) => COUNTRIES.includes(x)),
18
18
  protocols: (c.protocols ?? []).filter((x) => PROTOCOLS.includes(x)),
19
- speed: c.speed !== false,
19
+ speed: SPEED_TEST && c.speed !== false,
20
20
  firewall: c.firewall === true,
21
21
  random: c.random === true,
22
+ allPorts: c.allPorts === true,
22
23
  timeout: c.timeout >= MIN_TIMEOUT ? c.timeout : DEFAULT_TIMEOUT,
23
24
  docker: c.docker === true,
24
25
  };
@@ -50,24 +51,10 @@ export function saveDockerWireGuard(wireguard) {
50
51
  }
51
52
  catch { } // not being able to remember isn't worth failing the scan
52
53
  }
53
- /** The Windscribe login remembered for the docker containers; null when there's none */
54
- export function loadLogin() {
54
+ /** Deletes the Windscribe username and password that 0.6.0 saved for the docker containers: they log in with this computer's session now */
55
+ export function forgetLogin() {
55
56
  try {
56
- const { username, password } = JSON.parse(readFileSync(LOGIN_FILE, "utf8"));
57
- return typeof username === "string" && typeof password === "string" ? { username, password } : null;
57
+ rmSync(join(dirname(CONFIG), "login.json"), { force: true });
58
58
  }
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
59
+ catch { } // not being able to delete it isn't worth failing the scan
73
60
  }
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,12 +105,18 @@ 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
+ // ponytail: past this, more containers mostly add load on the one uplink, and WireGuard keys on the account; raise if real scans say so
109
+ const MAX_WORKERS = 16;
109
110
  /** Protocols the containers can't do, which a parallel scan runs on this computer after them: IKEv2 (Windscribe's Linux app
110
111
  * 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)));
112
+ export const notInContainers = (protocols, wireguard) => new Set(protocols.filter((p) => /^ikev2(:|$)/.test(p) || (!wireguard && /^wireguard(:|$)/.test(p))));
113
+ /** Containers for this many attempts, when docker can run at most `most`: one per attempt; past that, the fewest that need no
114
+ * more rounds (17 attempts, 16 at most: 9 containers make 2 rounds, as 16 would), since fewer log in sooner and register fewer keys */
115
+ export const containersFor = (attempts, most) => attempts <= most ? Math.max(1, attempts) : Math.ceil(attempts / Math.ceil(attempts / most));
116
+ // ponytail: rough seconds until n containers are logged in: in a test, 1 took 37 s, 16 at once 38-75 s; refine from real runs
117
+ export const loginSeconds = (containers) => 35 + 2.5 * containers;
118
+ /** Containers for a scan of this many attempts, within docker's CPUs and 3/4 of its memory */
119
+ export const workerCount = (attempts) => containersFor(attempts, Math.max(1, Math.min(MAX_WORKERS, info.cpus, Math.floor((info.memory * 0.75) / WORKER_MEMORY))));
114
120
  const DMG = `https://desktop.docker.com/mac/main/${process.arch === "arm64" ? "arm64" : "amd64"}/Docker.dmg`;
115
121
  const START_DESKTOP = process.platform === "win32"
116
122
  ? { label: "start Docker Desktop", cmd: () => `start "" "${DESKTOP}"` }
@@ -243,27 +249,22 @@ const running = new Map(); // container name → its `docker run`, until the con
243
249
  // a crash or a force quit leaves them running: remove them
244
250
  const removeAll = () => running.size && spawnSync("docker", ["rm", "-f", ...running.keys()], { stdio: "ignore" });
245
251
  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();
252
+ /** Starts n containers, their apps logged in with this computer's session; resolves to a prober for each container that got
253
+ * ready, and whether WireGuard works in them */
254
+ export async function startWorkers(n, session, onStatus) {
255
+ const measuring = mutex();
250
256
  let wireguard = true;
251
- let creds; // undefined: not asked yet; null: the user cancelled
252
257
  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
258
  const start = (i) => new Promise((resolve) => {
259
259
  const name = `windscout-${process.pid}-${i + 1}`;
260
- // its own volume keeps its Windscribe login for the next scans
260
+ // its own volume keeps its app's settings for the next scans: its WireGuard keys, registered with Windscribe once
261
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
262
  const p = spawn("docker", ["run", "-i", "--rm", "--init", "--name", name, "--cap-add", "NET_ADMIN", "--device-cgroup-rule", "c 10:200 rwm",
263
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
264
  running.set(name, p);
265
265
  const send = (m) => p.stdin.write(JSON.stringify(m) + "\n");
266
266
  p.stdin.on("error", () => { }); // it stopped: "close" deals with that
267
+ send({ session }); // on stdin, not in the command line or the environment, where ps and docker inspect show it
267
268
  let on = (_) => { }; // the current phase's message handler
268
269
  let fail = (_) => { }; // and what it does when the container stops
269
270
  let dead = "", err = "";
@@ -312,28 +313,14 @@ export async function startWorkers(n, prompt, onStatus) {
312
313
  };
313
314
  send({ probe: { query, protocol, timeout, speedTest } });
314
315
  });
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
- }
316
+ fail = (why) => (onStatus(`container ${i + 1}: ${why}`), resolve(null));
317
+ on = (m) => {
318
+ if (!m.ready)
319
+ return;
320
+ wireguard &&= m.wireguard !== false;
321
+ fail = () => { };
322
+ onStatus(`${++ready} of ${n} ready`);
323
+ resolve(prober);
337
324
  };
338
325
  });
339
326
  const probers = (await Promise.all(Array.from({ length: n }, (_, i) => start(i)))).filter((p) => p !== null);
package/dist/probe.js CHANGED
@@ -27,11 +27,18 @@ export async function probe(query, protocol, { timeout, speedTest, stopped = ()
27
27
  }
28
28
  else if (s.includes("does not exist"))
29
29
  return end("no such location");
30
+ // Windscribe refused this app another WireGuard key for the account: says nothing about this network
31
+ else if (s.includes("WireGuard key limit") && now() - t > 3)
32
+ return end("key limit");
30
33
  else if (s === "Disconnected" && now() - t > 3)
31
34
  return end("failed");
32
35
  onStep(`connecting ${r.location}`.trim());
33
36
  await sleep(1000);
34
37
  }
38
+ // a port the app doesn't offer for the protocol: it connects on the protocol's default instead, so this port wasn't tried
39
+ const port = protocol.split(":")[1], shown = (await windscribe("status")).match(/^Protocol: .*:(\d+)\s*$/m)?.[1];
40
+ if (port && shown && shown !== port)
41
+ return end("no such port");
35
42
  await measure(async () => {
36
43
  onStep(`measuring latency · ${r.location}`);
37
44
  r.latencyMs = await latencyMs();
@@ -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,9 +1,9 @@
1
- import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig } from "../config.js";
2
- import { dockerState, notInContainers, workerCount } from "../docker.js";
1
+ import { loadDockerWireGuard, MIN_TIMEOUT, saveConfig, SPEED_TEST } from "../config.js";
2
+ import { dockerState, loginSeconds, notInContainers, workerCount } from "../docker.js";
3
3
  import { attemptSeconds, estimate } from "../scan.js";
4
4
  import { duration } from "../time.js";
5
5
  import { canUpdate, checkUpdate, versionLines } from "../update.js";
6
- import { COUNTRIES, PROTOCOLS } from "../windscribe.js";
6
+ import { COUNTRIES, protocolPorts, PROTOCOLS } from "../windscribe.js";
7
7
  import { countryName, flag } from "./country.js";
8
8
  import { setupDocker } from "./docker.js";
9
9
  import { draw, enterScreen, input, leaveScreen, out, repoLink } from "./terminal.js";
@@ -11,18 +11,21 @@ import { bold, cyan, dim, green, under, vw, yellow } from "./text.js";
11
11
  import { box, centerIn, grid } from "./widgets.js";
12
12
  const flip = (set, value) => (set.has(value) ? set.delete(value) : set.add(value));
13
13
  /** Resolves to the choice (also saved for next time), null when the user quits, or "update" for u */
14
- export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
14
+ export function pick(saved, { timeout, speedTest, firewall, random, allPorts, docker }) {
15
15
  const selected = new Set(saved?.countries ?? []);
16
16
  const protos = new Set(saved?.protocols.length ? saved.protocols : PROTOCOLS);
17
17
  let cursor = 0, top = 0, message = "", query = "", searching = false;
18
18
  let seconds = String(timeout), editing = false; // the timeout, as typed
19
19
  let dockerAt = null, inDialog = false; // docker's state once checked; a docker dialog has the screen
20
+ let portLists = null; // each protocol's ports, once the app said
20
21
  let hit;
21
22
  const visible = () => {
22
23
  const q = query.toLowerCase();
23
24
  return q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q) || countryName(c).toLowerCase().includes(q)) : COUNTRIES;
24
25
  };
25
- const attempts = () => selected.size * protos.size;
26
+ const ports = (p) => (allPorts && portLists?.get(p)?.length) || 1; // attempts per country for the protocol
27
+ const perCountry = (ps) => [...ps].reduce((n, p) => n + ports(p), 0);
28
+ const attempts = () => selected.size * perCountry(protos);
26
29
  const wireguard = loadDockerWireGuard(); // in docker containers, as the last parallel scan found
27
30
  enterScreen();
28
31
  return new Promise((resolve) => {
@@ -40,6 +43,12 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
40
43
  top = cursor - height + 1;
41
44
  top = Math.max(0, Math.min(top, matches.length - height));
42
45
  const protoLine = PROTOCOLS.map((p, i) => `${dim(under(String(i + 1)))} ${protos.has(p) ? green("● " + p) : dim("○ " + p)}`).join(" ");
46
+ const listed = portLists && [...portLists.values()].some((l) => l.length), each = perCountry(protos);
47
+ const portsLine = `${dim(under("p"))} ${allPorts ? green("● all ports") : dim("○ all ports")} ${dim(!allPorts
48
+ ? "off: each protocol on its default port"
49
+ : !portLists ? "checking the ports…"
50
+ : !listed ? "this Windscribe app can't list its ports: 2.22 and newer can"
51
+ : `every port of each protocol: ${each} attempt${each === 1 ? "" : "s"} per country${each > protos.size ? `, not ${protos.size}` : ""}`)}`;
43
52
  const speedLine = `${dim(under("s"))} ${speedTest ? green("● speed test") : dim("○ speed test")} ${dim(speedTest
44
53
  ? "ranks by download speed · adds ~15s per working connection"
45
54
  : "off: connect-only, ranks by latency")}`;
@@ -55,8 +64,10 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
55
64
  const timeoutLine = `${dim(under("t"))} timeout ${editing ? bold(seconds) + cyan("▏") : (short ? yellow : bold)(seconds || "?")} s ${dim(`per connection · at least ${MIN_TIMEOUT}`)}`;
56
65
  const n = attempts();
57
66
  const here = PROTOCOLS.filter((p) => docker && protos.has(p) && notInContainers(PROTOCOLS, wireguard).has(p)); // after the containers
58
- const inContainers = selected.size * (protos.size - here.length);
59
- const slots = docker && dockerAt === "ready" && inContainers ? workerCount(inContainers) : 1;
67
+ const inContainers = selected.size * perCountry([...protos].filter((p) => !here.includes(p)));
68
+ const parallel = docker && dockerAt === "ready" && inContainers > 0;
69
+ const slots = parallel ? workerCount(inContainers) : 1;
70
+ const login = parallel ? loginSeconds(slots) : 0; // the containers log in before the scan starts
60
71
  const dockerLabel = "parallel scan in docker containers";
61
72
  const dockerLine = `${dim(under("d"))} ${docker ? green("● " + dockerLabel) : dim("○ " + dockerLabel)} ${dim(!docker
62
73
  ? "off: one attempt at a time"
@@ -82,9 +93,10 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
82
93
  ["quit", under("Q") + "uit"],
83
94
  ];
84
95
  const buttonLine = buttons.map(([id, label]) => (id === "start" ? bold(cyan(`[ ${label} ]`)) : dim(`[ ${label} ]`))).join(" ");
85
- const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...[protoLine, speedLine, fwLine, randomLine, timeoutLine, dockerLine].map(vw)) + 4 });
96
+ const settings = [protoLine, portsLine, ...(SPEED_TEST ? [speedLine] : []), fwLine, randomLine, timeoutLine, dockerLine];
97
+ const table = grid([{ title: "" }, { title: "Code" }, { title: "Country" }], body, { caption: search, captionRight: dim(range), minWidth: Math.max(...settings.map(vw)) + 4 });
86
98
  const width = vw(table[0]);
87
- const head = box([protoLine, speedLine, fwLine, randomLine, timeoutLine, dockerLine], {
99
+ const head = box(settings, {
88
100
  title: bold(cyan("WindScout")),
89
101
  right: `${bold(String(selected.size))}${dim(`/${COUNTRIES.length} selected`)}`,
90
102
  width: width - 4,
@@ -96,7 +108,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
96
108
  centerIn(buttonLine, width),
97
109
  "",
98
110
  centerIn(n
99
- ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""})`)}`
111
+ ? `${bold(String(n))} attempts · estimated ${bold("≈ " + duration(login + estimate(inContainers, timeout, slots) + estimate(n - inContainers, timeout)))} ${dim(`(~${attemptSeconds(timeout)}s each${slots > 1 ? `, ${slots} at a time` : ""}${login ? `, + ~${duration(login)} login` : ""})`)}`
100
112
  : yellow("select at least one country and one protocol"), width),
101
113
  centerIn(dim(editing
102
114
  ? "type the seconds to wait for each connection · enter/esc done"
@@ -104,13 +116,14 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
104
116
  ? "type to filter · ↑↓ move · enter/esc done"
105
117
  // both lines fit 80 columns
106
118
  : `↑↓ move · space select · a/n ${query ? "all/none shown · esc clear" : "all/none · / search"} · enter start · q quit`), width),
107
- centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · s speed test · f firewall · r random · t timeout · d docker`), width),
119
+ centerIn(dim(searching || editing ? "" : `1-${PROTOCOLS.length} protocols · p ports${SPEED_TEST ? " · s speed" : ""} · f firewall · r random · t timeout · d docker`), width),
108
120
  ];
109
121
  if (message)
110
122
  lines.push(centerIn(yellow(message), width));
111
123
  lines.push("", ...version.map((l) => centerIn(l, width)), centerIn(dim(repoLink), width));
112
124
  const at = draw(lines);
113
125
  // click map, in screen coordinates
126
+ const row = (line) => at.top + 1 + settings.indexOf(line); // a settings line, under the box's top border
114
127
  let px = at.left + 2; // "│ " before the protocol line
115
128
  const protoHits = PROTOCOLS.map((p, i) => {
116
129
  const len = vw(`${i + 1} ● ${p}`);
@@ -126,7 +139,8 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
126
139
  return range;
127
140
  });
128
141
  hit = {
129
- protoRow: at.top + 1, protoHits, speedRow: at.top + 2, firewallRow: at.top + 3, randomRow: at.top + 4, timeoutRow: at.top + 5, dockerRow: at.top + 6,
142
+ protoRow: row(protoLine), protoHits, portsRow: row(portsLine), speedRow: SPEED_TEST ? row(speedLine) : -1,
143
+ firewallRow: row(fwLine), randomRow: row(randomLine), timeoutRow: row(timeoutLine), dockerRow: row(dockerLine),
130
144
  searchRow: at.top + head.length + 1,
131
145
  firstRow: at.top + head.length + 5, shown: shown.length,
132
146
  buttonRow: at.top + head.length + table.length + 1, buttonHits,
@@ -136,6 +150,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
136
150
  checkUpdate().then(() => !closed && render()); // the check usually lands after the first draw
137
151
  if (docker)
138
152
  dockerState().then((s) => ((dockerAt = s), !closed && render()));
153
+ protocolPorts().then((l) => ((portLists = l), !closed && render()));
139
154
  const finish = (value) => {
140
155
  closed = true;
141
156
  out.off("resize", render);
@@ -161,6 +176,7 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
161
176
  speed: speedTest,
162
177
  firewall,
163
178
  random,
179
+ allPorts,
164
180
  timeout,
165
181
  docker,
166
182
  };
@@ -214,6 +230,8 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
214
230
  if (i >= 0)
215
231
  flip(protos, PROTOCOLS[i]);
216
232
  }
233
+ else if (y === hit.portsRow)
234
+ allPorts = !allPorts;
217
235
  else if (y === hit.speedRow)
218
236
  speedTest = !speedTest;
219
237
  else if (y === hit.firewallRow)
@@ -294,7 +312,9 @@ export function pick(saved, { timeout, speedTest, firewall, random, docker }) {
294
312
  matches.forEach((c) => selected.add(c));
295
313
  else if (k === "n")
296
314
  matches.forEach((c) => selected.delete(c));
297
- else if (k === "s")
315
+ else if (k === "p")
316
+ allPorts = !allPorts;
317
+ else if (k === "s" && SPEED_TEST)
298
318
  speedTest = !speedTest;
299
319
  else if (k === "f")
300
320
  firewall = !firewall;
@@ -24,7 +24,9 @@ export function showProgress(scan, blocking) {
24
24
  const eta = (total - done) * (done ? elapsed / done : attemptSeconds(timeout) / slots.length);
25
25
  const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10];
26
26
  const ok = sortOk(results);
27
- const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok"));
27
+ // blocked: nothing worked, and something was really tried (a key limit or a port the app doesn't offer tries nothing)
28
+ const failed = finished.filter((c) => !results.some((r) => r.query === c && r.status === "ok")
29
+ && results.some((r) => r.query === c && r.status !== "key limit" && r.status !== "no such port"));
28
30
  const attempt = ({ current: c }) => (c ? `${flag(c.country)} ${countryName(c.country)} ${dim("·")} ${c.protocol}` : "");
29
31
  const many = slots.length > 1; // a parallel scan: a line per container
30
32
  const pct = bold(`${String(Math.floor(fraction * 100)).padStart(3)}%`);
@@ -30,6 +30,15 @@ export function report(results, protocols, { all, speedTest }) {
30
30
  console.log();
31
31
  if (avail.length)
32
32
  console.log(avail.join("\n"));
33
+ const limited = results.filter((r) => r.status === "key limit");
34
+ if (limited.length) {
35
+ const many = limited.length > 1, list = (xs) => [...new Set(xs)].join(",");
36
+ console.log([
37
+ yellow(`⚠ ${limited.length} WireGuard attempt${many ? "s" : ""} hit your Windscribe account's limit of WireGuard keys`),
38
+ dim(` (each docker container registers a key of its own), so ${many ? "these don't" : "it doesn't"} show whether WireGuard works from here.`),
39
+ ` To find out, scan ${many ? "them" : "it"} on this computer: ${cyan(`windscout -c "${list(limited.map((r) => r.query))}" -p ${list(limited.map((r) => r.protocol))}`)}`,
40
+ ].join("\n"));
41
+ }
33
42
  if (!ok.length) {
34
43
  console.log(box([yellow("Nothing connected."),
35
44
  dim("Try other countries, ports (e.g. -p stealth:443,tcp:443) or a longer --timeout.")], { title: bold(cyan("WindScout")), width: width - 4 }).join("\n"));
@@ -37,7 +46,7 @@ export function report(results, protocols, { all, speedTest }) {
37
46
  }
38
47
  console.log(main.join("\n"));
39
48
  const stats = protocols.map((p) => {
40
- const tried = results.filter((r) => r.protocol === p && r.status !== "no such location");
49
+ const tried = results.filter((r) => r.protocol === p && !["no such location", "key limit", "no such port"].includes(r.status));
41
50
  const good = tried.filter((r) => r.status === "ok");
42
51
  return { p, good: good.length, tried: tried.length, best: sortOk(good).at(0) };
43
52
  }).sort((x, y) => y.good - x.good || (y.best?.speedMbps ?? 0) - (x.best?.speedMbps ?? 0) || (x.best?.latencyMs ?? 1e9) - (y.best?.latencyMs ?? 1e9));
@@ -1,5 +1,5 @@
1
1
  import { countryName, flag } from "./country.js";
2
- import { bold, dim, fmt, green, red } from "./text.js";
2
+ import { bold, dim, fmt, green, red, yellow } from "./text.js";
3
3
  import { grid } from "./widgets.js";
4
4
  const RESULT_COLS = [
5
5
  { title: "#", align: "right" }, { title: "Country" }, { title: "Location" }, { title: "Protocol" },
@@ -14,15 +14,27 @@ const resultRow = (r, i) => [
14
14
  r.connectS === null ? "-" : `${fmt(r.connectS, 1)} s`,
15
15
  r.latencyMs === null ? "-" : `${fmt(r.latencyMs, 0)} ms`,
16
16
  r.speedMbps === null ? "-" : bold(`${fmt(r.speedMbps, 1)} Mbps`),
17
- r.status === "ok" ? green("✓ ok") : red(r.status),
17
+ r.status === "ok" ? green("✓ ok") : r.status === "key limit" || r.status === "no such port" ? yellow(r.status) : red(r.status),
18
18
  ];
19
19
  /** Numbered attempts, in the given order; the Speed column only when the speed test ran */
20
20
  export function resultTable(results, speedTest, options) {
21
21
  const keep = (_, i) => speedTest || i !== SPEED_COL;
22
22
  return grid(RESULT_COLS.filter(keep), results.map((r, i) => resultRow(r, i).filter(keep)), options);
23
23
  }
24
- /** One row per country, ✓/✗ per protocol (· = not tried yet); no lines until something was tried */
24
+ const base = (protocol) => protocol.split(":")[0];
25
+ /** A protocol's cell: ✓/✗, with how many of its ports worked when it has several (✓ 3/11); · = not tried yet,
26
+ * key limit = couldn't try WireGuard, no port = the app doesn't offer it. Those last two don't count */
27
+ function cell(rs, ports) {
28
+ const tried = rs.filter((r) => ports.includes(r.protocol));
29
+ const real = tried.filter((r) => r.status !== "key limit" && r.status !== "no such port");
30
+ const good = real.filter((r) => r.status === "ok").length, count = ports.length > 1 ? ` ${good}/${real.length}` : "";
31
+ if (real.length)
32
+ return good ? green(`✓${count}`) : red(`✗${count}`);
33
+ return tried.some((r) => r.status === "key limit") ? yellow("key limit") : tried.length ? yellow("no port") : dim("·");
34
+ }
35
+ /** One row per country, a cell per protocol (its ports share it); no lines until something was tried */
25
36
  export function matrix(results, protocols, { minWidth = 0, limit = Infinity } = {}) {
37
+ const columns = [...new Set(protocols.map(base))].map((b) => protocols.filter((p) => base(p) === b));
26
38
  const byCountry = new Map();
27
39
  for (const r of results) {
28
40
  if (r.status === "no such location" || r.status === "interrupted")
@@ -45,15 +57,13 @@ export function matrix(results, protocols, { minWidth = 0, limit = Infinity } =
45
57
  [
46
58
  `${flag(q)} ${countryName(q)}`,
47
59
  first,
48
- ...protocols.map((p) => {
49
- const r = rs.find((x) => x.protocol === p);
50
- return !r ? dim("·") : r.status === "ok" ? green("✓") : red("✗");
51
- }),
60
+ ...columns.map((ports) => cell(rs, ports)),
52
61
  ],
53
- ...more.map((place) => ["", place, ...protocols.map(() => "")]),
62
+ ...more.map((place) => ["", place, ...columns.map(() => "")]),
54
63
  ];
55
64
  }).slice(0, limit);
56
- return grid([{ title: "Country" }, { title: "Location(s)" }, ...protocols.map((p) => ({ title: p, align: "center" }))], rows, {
65
+ return grid([{ title: "Country" }, { title: "Location(s)" },
66
+ ...columns.map((ports) => ({ title: ports.length > 1 ? base(ports[0]) : ports[0], align: "center" }))], rows, {
57
67
  caption: bold("Availability"),
58
68
  captionRight: dim(`${reachable}/${ranked.length} countries reachable${ranked.length > limit ? ` · top ${limit}` : ""}`),
59
69
  minWidth,
@@ -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";
@@ -14,6 +15,19 @@ const DESKTOP_CLI = {
14
15
  let cliBin = CLI;
15
16
  // IKEv2 is offered by the macOS/Windows apps only
16
17
  export const PROTOCOLS = ["wireguard", ...(process.platform === "linux" ? [] : ["ikev2"]), "stealth", "wstunnel", "udp", "tcp"];
18
+ let portLists;
19
+ /** The ports the app offers for each protocol, its default first; [] where it lists none (IKEv2 on Linux, or apps before 2.22,
20
+ * which have no `ports`). Asked once, one protocol at a time: windscribe-cli runs one at a time */
21
+ export const protocolPorts = () => (portLists ??= (async () => {
22
+ const lists = new Map();
23
+ for (const p of PROTOCOLS) {
24
+ const out = await windscribe("ports", p).catch(() => ""); // can't ask: no list, so the default port
25
+ lists.set(p, out.match(/^\d+(, \d+)*$/m)?.[0].split(", ").map(Number) ?? []);
26
+ }
27
+ return lists;
28
+ })());
29
+ /** Each protocol given without a port, on every port in lists: "tcp" → "tcp:443", "tcp:587", …; the rest as given */
30
+ export const withPorts = (protocols, lists) => protocols.flatMap((p) => (!p.includes(":") && lists.get(p)?.length ? lists.get(p).map((port) => `${p}:${port}`) : [p]));
17
31
  // ponytail: hardcoded because `windscribe-cli locations` returns nothing on 2.24; refresh when Windscribe adds countries
18
32
  export const COUNTRIES = `US CA MX BR AR CL CO PE GB IE FR DE NL BE LU CH AT IT ES PT DK NO SE FI IS
19
33
  PL CZ SK HU RO BG GR CY TR RS HR SI BA MK AL MD UA EE LV LT IL AE AZ GE AM KZ IN JP KR
@@ -69,6 +83,51 @@ export async function waitForLogin() {
69
83
  + `\nDon't have it? ${DOWNLOAD_URL}`);
70
84
  return status;
71
85
  }
86
+ // The app's settings (Qt's, as Windscribe/Windscribe2): this INI file on Linux, the user's defaults on macOS, the registry on Windows
87
+ export const SETTINGS = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "Windscribe", "Windscribe2.conf");
88
+ /** A top-level value in the text of the INI file: Qt puts those under [General] */
89
+ export function iniValue(ini, key) {
90
+ let general = false;
91
+ for (const line of ini.split(/\r?\n/)) {
92
+ if (line.startsWith("["))
93
+ general = line.trim() === "[General]";
94
+ else if (general && line.startsWith(`${key}=`))
95
+ return line.slice(key.length + 1);
96
+ }
97
+ }
98
+ /** The INI file's text with the app logging in with this session when it starts. Settings another account left start over:
99
+ * the WireGuard keys in them are registered with that account */
100
+ export function withSession(ini, { userId, authHash }) {
101
+ if (userId && iniValue(ini, "userId") !== userId)
102
+ ini = "";
103
+ let general = false;
104
+ const lines = ini.split("\n").filter((line) => {
105
+ if (line.startsWith("["))
106
+ general = line.trim() === "[General]";
107
+ return !(general && line.startsWith("authHash="));
108
+ });
109
+ const at = lines.findIndex((line) => line.trim() === "[General]");
110
+ lines.splice(at + 1, 0, ...(at < 0 ? ["[General]"] : []), `authHash=${authHash}`);
111
+ return lines.join("\n");
112
+ }
113
+ /** 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 */
114
+ function setting(key) {
115
+ const run = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
116
+ try {
117
+ const value = process.platform === "darwin" ? run("defaults", ["read", "com.windscribe.Windscribe2", key])
118
+ : process.platform === "win32" ? run("reg", ["query", "HKCU\\Software\\Windscribe\\Windscribe2", "/v", key]).match(/REG_SZ\s+(.*)/)?.[1]
119
+ : iniValue(readFileSync(SETTINGS, "utf8"), key);
120
+ return value?.trim().match(/^[\w:.+/=-]+$/)?.[0] ?? "";
121
+ }
122
+ catch {
123
+ return ""; // no such setting, or no settings
124
+ }
125
+ }
126
+ /** The session this computer's app is logged in with; null when its settings don't have one */
127
+ export function session() {
128
+ const authHash = setting("authHash");
129
+ return authHash ? { userId: setting("userId"), authHash } : null;
130
+ }
72
131
  export const isFirewallOn = (status) => /^Firewall state: On/m.test(status);
73
132
  let firewallChanged = false; // blockInternet() turned the firewall on, so restoreFirewall() turns it back off
74
133
  /** 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,28 @@ 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.
45
+ // One container took 37 s in a test, 16 at once 38-75 s
30
46
  let status = "";
31
- for (let i = 0; i < 60 && !(status = await windscribe("status")).includes("Login state"); i++)
47
+ for (const t = now(); now() - t < 180 && !/Login state: (Logged in|Error)/.test(status = await windscribe("status"));)
32
48
  await sleep(1000);
33
- if (!status.includes("Login state"))
34
- return void console.error("Windscribe's app didn't start in the container");
35
49
  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
- }
50
+ const state = status.match(/^Login state: (.*)$/m)?.[1].trim();
51
+ 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
52
  }
45
53
  send({ ready: true, wireguard: process.env.WS_WIREGUARD !== "0" }); // the image's CMD checked the kernel
46
54
  for (let m; (m = await next());) {
@@ -66,27 +74,3 @@ export async function worker() {
66
74
  send({ result });
67
75
  }
68
76
  }
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,7 +1,7 @@
1
1
  {
2
2
  "name": "windscout",
3
- "version": "0.6.0",
4
- "description": "Find which Windscribe locations and protocols work from your network, ranked by speed (unofficial)",
3
+ "version": "0.6.4",
4
+ "description": "Find which Windscribe locations and protocols work from your network, ranked by latency (unofficial)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "windscout": "dist/cli.js"
@@ -25,14 +25,12 @@
25
25
  "keywords": [
26
26
  "windscribe",
27
27
  "vpn",
28
- "speedtest",
29
28
  "wireguard",
30
29
  "stealth",
31
30
  "cli",
32
31
  "censorship",
33
32
  "firewall",
34
33
  "unblock",
35
- "vpn-speed-test",
36
34
  "server-finder",
37
35
  "docker"
38
36
  ],