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 +42 -23
- package/dist/cli.js +23 -11
- package/dist/config.js +9 -22
- package/dist/docker.js +26 -39
- package/dist/probe.js +7 -0
- package/dist/tui/docker.js +37 -112
- package/dist/tui/picker.js +33 -13
- package/dist/tui/progress.js +3 -1
- package/dist/tui/report.js +10 -1
- package/dist/tui/tables.js +19 -9
- package/dist/windscribe.js +61 -2
- package/dist/worker.js +24 -40
- package/package.json +2 -4
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
|
|
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
|
|
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
|

|
|
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
|
-
│
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
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
|
-
##
|
|
91
|
+
## Availability grid
|
|
91
92
|
|
|
92
|
-
|
|
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
|
|
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,
|
|
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:**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 │
|
|
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 ·
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
54
|
-
export function
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
112
|
-
/** Containers for
|
|
113
|
-
|
|
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
|
|
247
|
-
* and whether WireGuard works in them */
|
|
248
|
-
export async function startWorkers(n,
|
|
249
|
-
const measuring = 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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
else
|
|
324
|
-
(unused = true), p.stdin.end(); // none: the worker gives up, its container stops
|
|
325
|
-
}
|
|
326
|
-
else if (m.need === "input")
|
|
327
|
-
send({ input: (await prompt.answer(m.text ?? "")) ?? "" });
|
|
328
|
-
else if (m.ready) {
|
|
329
|
-
leave?.();
|
|
330
|
-
if (used)
|
|
331
|
-
prompt.loggedIn(used);
|
|
332
|
-
wireguard &&= m.wireguard !== false;
|
|
333
|
-
fail = () => { };
|
|
334
|
-
onStatus(`${++ready} of ${n} ready`);
|
|
335
|
-
resolve(prober);
|
|
336
|
-
}
|
|
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();
|
package/dist/tui/docker.js
CHANGED
|
@@ -1,33 +1,17 @@
|
|
|
1
|
-
// Docker on screen, for parallel scans: dialogs to install or start it (taking the sudo password),
|
|
2
|
-
// progress while the image builds and the containers start, and the Windscribe login the containers need.
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { LOGIN_FILE, loadLogin, saveLogin } from "../config.js";
|
|
5
1
|
import { buildImage, dockerState, INSTALL_DOCS, runSteps, startWorkers, steps, sudoLogin, sudoReady, waitReady, workerCount } from "../docker.js";
|
|
6
2
|
import { duration, now } from "../time.js";
|
|
3
|
+
import { session } from "../windscribe.js";
|
|
7
4
|
import { draw, enterScreen, input, leaveScreen, out } from "./terminal.js";
|
|
8
|
-
import { bold, cyan, dim,
|
|
5
|
+
import { bold, cyan, dim, vw, yellow } from "./text.js";
|
|
9
6
|
import { box, centerIn } from "./widgets.js";
|
|
10
7
|
const tty = () => Boolean(process.stdin.isTTY && out.isTTY);
|
|
11
8
|
const clip = (s, n = 76) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
12
9
|
// lines of at most n columns, broken at spaces: an error can be longer than the screen is wide
|
|
13
10
|
const wrap = (s, n = 76) => s.match(new RegExp(`.{1,${n}}(\\s+|$)|\\S+`, "g"))?.map((l) => l.trim()) ?? [s];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const body = [...lines], items = [];
|
|
19
|
-
if (fields.length || check)
|
|
20
|
-
body.push("");
|
|
21
|
-
fields.forEach((f, i) => {
|
|
22
|
-
items.push(body.length);
|
|
23
|
-
body.push(`${mark(i)}${f.label} ${bold(f.secret ? "•".repeat(a.values[i].length) : a.values[i])}${i === a.focus ? cyan("▏") : ""}`);
|
|
24
|
-
});
|
|
25
|
-
if (check) {
|
|
26
|
-
items.push(body.length);
|
|
27
|
-
body.push(`${mark(fields.length)}${a.checked ? green("[✓]") : dim("[ ]")} ${check.label}`);
|
|
28
|
-
if (check.note)
|
|
29
|
-
body.push(dim(`${many ? " " : ""} ${check.note}`));
|
|
30
|
-
}
|
|
11
|
+
function paint({ lines, password, buttons, error }, typed = "") {
|
|
12
|
+
const body = [...lines];
|
|
13
|
+
if (password !== undefined)
|
|
14
|
+
body.push("", `${password} ${bold("•".repeat(typed.length))}${cyan("▏")}`);
|
|
31
15
|
if (error)
|
|
32
16
|
body.push("", yellow(error));
|
|
33
17
|
const width = Math.max(56, ...body.map(vw));
|
|
@@ -36,86 +20,53 @@ function paint({ lines, fields = [], check, buttons, error }, a = { values: [],
|
|
|
36
20
|
const [ok, cancel] = buttons;
|
|
37
21
|
line = `${bold(cyan(`[ ${ok} ]`))}${cancel ? dim(` [ ${cancel} ]`) : ""}`;
|
|
38
22
|
row = body.length + 1;
|
|
39
|
-
body.push("", centerIn(line, width), centerIn(dim([
|
|
40
|
-
cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
23
|
+
body.push("", centerIn(line, width), centerIn(dim([`enter ${ok.toLowerCase()}`, cancel && `esc ${cancel.toLowerCase()}`].filter(Boolean).join(" · ")), width));
|
|
41
24
|
}
|
|
42
25
|
enterScreen();
|
|
43
26
|
const at = draw(box(body, { title: bold(cyan("WindScout · docker")), width }));
|
|
44
|
-
// a body line sits a row under the box's top border, two columns right of its left one
|
|
45
|
-
const rows = items.map((i) => at.top + 1 + i);
|
|
46
27
|
if (!buttons)
|
|
47
|
-
return {
|
|
28
|
+
return { row: -1, ok: [0, 0], cancel: [0, 0] };
|
|
29
|
+
// a body line sits a row under the box's top border, two columns right of its left one
|
|
48
30
|
const x = at.left + 2 + Math.floor((width - vw(line)) / 2), okEnd = x + vw(`[ ${buttons[0]} ]`);
|
|
49
|
-
return {
|
|
31
|
+
return { row: at.top + 1 + row, ok: [x, okEnd], cancel: buttons[1] ? [okEnd + 2, okEnd + 2 + vw(`[ ${buttons[1]} ]`)] : [0, 0] };
|
|
50
32
|
}
|
|
51
|
-
/** Shows the dialog until the user answers: what was typed
|
|
52
|
-
|
|
53
|
-
function form(d) {
|
|
33
|
+
/** Shows the dialog until the user answers: what was typed in its field ("" without one) on enter or a click on ok, null on esc */
|
|
34
|
+
function ask(d) {
|
|
54
35
|
if (!tty())
|
|
55
36
|
return Promise.resolve(null);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const a = { values: fields.map((f) => f.value ?? ""), checked: d.check?.on ?? false, focus: 0 };
|
|
60
|
-
let hits = paint(d, a);
|
|
61
|
-
const redraw = () => (hits = paint(d, a));
|
|
37
|
+
let typed = "";
|
|
38
|
+
let hits = paint(d, typed);
|
|
39
|
+
const redraw = () => (hits = paint(d, typed));
|
|
62
40
|
return new Promise((resolve) => {
|
|
63
41
|
const done = (v) => {
|
|
64
|
-
asking = false;
|
|
65
42
|
input.off("keypress", onKey);
|
|
66
43
|
input.off("mouse", onMouse);
|
|
67
44
|
out.off("resize", redraw);
|
|
68
45
|
resolve(v);
|
|
69
46
|
};
|
|
70
47
|
const onKey = (str, key = {}) => {
|
|
71
|
-
const field = a.focus < fields.length;
|
|
72
48
|
if (key.name === "escape" || (key.ctrl && key.name === "c"))
|
|
73
49
|
return done(null);
|
|
74
|
-
if (key.name === "return")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
a.focus = (a.focus + (key.shift ? n - 1 : 1)) % n;
|
|
81
|
-
else if (n > 1 && key.name === "up")
|
|
82
|
-
a.focus = (a.focus + n - 1) % n;
|
|
83
|
-
else if (!field) {
|
|
84
|
-
if (key.name === "space")
|
|
85
|
-
a.checked = !a.checked;
|
|
86
|
-
}
|
|
87
|
-
else if (key.name === "backspace")
|
|
88
|
-
a.values[a.focus] = a.values[a.focus].slice(0, -1);
|
|
89
|
-
else if (str && !key.ctrl && !key.meta && str >= " ")
|
|
90
|
-
a.values[a.focus] += str;
|
|
50
|
+
if (key.name === "return")
|
|
51
|
+
return done(typed);
|
|
52
|
+
if (key.name === "backspace")
|
|
53
|
+
typed = typed.slice(0, -1);
|
|
54
|
+
else if (d.password !== undefined && str && !key.ctrl && !key.meta && str >= " ")
|
|
55
|
+
typed += str;
|
|
91
56
|
redraw();
|
|
92
57
|
};
|
|
93
58
|
const onMouse = (m) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const on = ([from, to]) => m.y === hits.buttons && m.x >= from && m.x < to;
|
|
98
|
-
if (i >= 0) {
|
|
99
|
-
a.focus = i;
|
|
100
|
-
if (i === fields.length)
|
|
101
|
-
a.checked = !a.checked; // the checkbox
|
|
102
|
-
}
|
|
103
|
-
else if (on(hits.ok))
|
|
104
|
-
return done(a);
|
|
59
|
+
const on = ([from, to]) => m.press && m.button === 0 && m.y === hits.row && m.x >= from && m.x < to;
|
|
60
|
+
if (on(hits.ok))
|
|
61
|
+
done(typed);
|
|
105
62
|
else if (on(hits.cancel))
|
|
106
|
-
|
|
107
|
-
redraw();
|
|
63
|
+
done(null);
|
|
108
64
|
};
|
|
109
65
|
input.on("keypress", onKey);
|
|
110
66
|
input.on("mouse", onMouse);
|
|
111
67
|
out.on("resize", redraw);
|
|
112
68
|
});
|
|
113
69
|
}
|
|
114
|
-
/** A dialog with one field or none: the field's text ("" without one) on enter, null on esc */
|
|
115
|
-
const ask = async (d) => {
|
|
116
|
-
const a = await form(d);
|
|
117
|
-
return a && (a.values[0] ?? "");
|
|
118
|
-
};
|
|
119
70
|
/** Waits for work while the screen shows a spinner, status.label, the time so far and status.line (its latest output). esc quits */
|
|
120
71
|
async function busy(status, work) {
|
|
121
72
|
if (!tty()) {
|
|
@@ -124,10 +75,10 @@ async function busy(status, work) {
|
|
|
124
75
|
}
|
|
125
76
|
const t = now();
|
|
126
77
|
let frame = 0;
|
|
127
|
-
const tick = () =>
|
|
78
|
+
const tick = () => paint({ lines: [`${cyan("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[frame++ % 10])} ${status.label} ${dim("· " + duration(now() - t))}`,
|
|
128
79
|
dim(clip(status.line)), "", dim("esc quits")] });
|
|
129
|
-
//
|
|
130
|
-
const quit = (_, key = {}) =>
|
|
80
|
+
// the exit handlers remove containers already started
|
|
81
|
+
const quit = (_, key = {}) => (key.name === "escape" || key.name === "q" || (key.ctrl && key.name === "c"))
|
|
131
82
|
&& (leaveScreen(), process.exit(130));
|
|
132
83
|
const timer = setInterval(tick, 120);
|
|
133
84
|
input.on("keypress", quit);
|
|
@@ -160,7 +111,7 @@ export async function setupDocker() {
|
|
|
160
111
|
: [])];
|
|
161
112
|
let password, error = "";
|
|
162
113
|
for (;;) {
|
|
163
|
-
const a = await ask({ lines,
|
|
114
|
+
const a = await ask({ lines, password: root ? "password for sudo:" : undefined, buttons: [state === "missing" ? "Install" : "Start", "Cancel"], error });
|
|
164
115
|
if (a === null)
|
|
165
116
|
return false;
|
|
166
117
|
if (!root)
|
|
@@ -193,49 +144,23 @@ export async function setupDocker() {
|
|
|
193
144
|
return a !== null && setupDocker();
|
|
194
145
|
}
|
|
195
146
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"the login stays in the container's docker volume for the next scans.",
|
|
199
|
-
];
|
|
200
|
-
let lastUser = "", remember = true; // the box as last ticked: ticked to start with
|
|
201
|
-
/** A remembered login unless that just failed, else the login form */
|
|
202
|
-
async function credentials(error) {
|
|
203
|
-
const saved = loadLogin();
|
|
204
|
-
if (saved && !error)
|
|
205
|
-
return saved;
|
|
206
|
-
const a = await form({
|
|
207
|
-
lines: LOGIN,
|
|
208
|
-
fields: [{ label: "Windscribe username:", value: lastUser || saved?.username }, { label: "Windscribe password:", secret: true }],
|
|
209
|
-
check: { label: "remember username and password", on: remember,
|
|
210
|
-
note: `saved in ${LOGIN_FILE.replace(homedir(), "~")}, only you can read it` },
|
|
211
|
-
buttons: ["Log in", "Cancel"],
|
|
212
|
-
error,
|
|
213
|
-
});
|
|
214
|
-
if (!a)
|
|
215
|
-
return null;
|
|
216
|
-
remember = a.checked;
|
|
217
|
-
if (!remember)
|
|
218
|
-
saveLogin(null); // unticked: forget the one saved before, if any
|
|
219
|
-
const [username, password] = a.values;
|
|
220
|
-
return { username: (lastUser = username), password };
|
|
221
|
-
}
|
|
222
|
-
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the logins.
|
|
223
|
-
* Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
147
|
+
/** Starts the containers for a parallel scan of this many attempts: docker, the image, the containers' apps logged in with
|
|
148
|
+
* this computer's session. Resolves to their probers and whether WireGuard works in them; null if the user backed out */
|
|
224
149
|
export async function startParallel(attempts) {
|
|
150
|
+
const s = session();
|
|
151
|
+
if (!s)
|
|
152
|
+
throw new Error("can't find the session this computer's Windscribe app is logged in with, which the containers' apps log in with:"
|
|
153
|
+
+ " update the Windscribe app, or scan without --docker (d in the picker)");
|
|
225
154
|
if (!(await setupDocker()))
|
|
226
155
|
return null;
|
|
227
156
|
const status = { label: "building the containers' image (the first time takes a few minutes)", line: "" };
|
|
228
157
|
await busy(status, buildImage((line) => (status.line = line)));
|
|
229
158
|
const n = workerCount(attempts);
|
|
230
159
|
Object.assign(status, { label: `starting ${n} container${n > 1 ? "s" : ""}`, line: "" });
|
|
231
|
-
const started = await busy(status, startWorkers(n,
|
|
232
|
-
credentials,
|
|
233
|
-
answer: (question) => ask({ lines: ["Windscribe asks:", "", ...question.split("\n")], fields: [{ label: "answer:" }], buttons: ["Send", "Cancel"] }),
|
|
234
|
-
loggedIn: (c) => remember && saveLogin(c), // saved only once it worked
|
|
235
|
-
}, (line) => (status.line = line)));
|
|
160
|
+
const started = await busy(status, startWorkers(n, s, (line) => (status.line = line)));
|
|
236
161
|
if (!started.probers.length) {
|
|
237
162
|
const why = status.line.startsWith("container") ? ` (${status.line})` : ""; // the last container's trouble
|
|
238
|
-
throw new Error(
|
|
163
|
+
throw new Error(`no container got ready${why}`);
|
|
239
164
|
}
|
|
240
165
|
return started;
|
|
241
166
|
}
|
package/dist/tui/picker.js
CHANGED
|
@@ -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
|
|
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.
|
|
59
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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 === "
|
|
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;
|
package/dist/tui/progress.js
CHANGED
|
@@ -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
|
-
|
|
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)}%`);
|
package/dist/tui/report.js
CHANGED
|
@@ -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 &&
|
|
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));
|
package/dist/tui/tables.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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, ...
|
|
62
|
+
...more.map((place) => ["", place, ...columns.map(() => "")]),
|
|
54
63
|
];
|
|
55
64
|
}).slice(0, limit);
|
|
56
|
-
return grid([{ title: "Country" }, { title: "Location(s)" },
|
|
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,
|
package/dist/windscribe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Windscribe desktop app, driven through the windscribe-cli it ships with.
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { sleep } from "./time.js";
|
|
6
7
|
export const CLI = "windscribe-cli";
|
|
@@ -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 {
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
const m = await next();
|
|
39
|
-
if (!m?.login)
|
|
40
|
-
return; // no credentials for us: this container sits out the scan
|
|
41
|
-
if (!(error = await login(m.login.username, m.login.password, next)))
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
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.
|
|
4
|
-
"description": "Find which Windscribe locations and protocols work from your network, ranked by
|
|
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
|
],
|