bridge-checker 1.0.0__tar.gz
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.
- bridge_checker-1.0.0/LICENSE +21 -0
- bridge_checker-1.0.0/PKG-INFO +173 -0
- bridge_checker-1.0.0/README.md +146 -0
- bridge_checker-1.0.0/bridge_checker/__init__.py +3 -0
- bridge_checker-1.0.0/bridge_checker/__main__.py +6 -0
- bridge_checker-1.0.0/bridge_checker/checker.py +49 -0
- bridge_checker-1.0.0/bridge_checker/cli.py +79 -0
- bridge_checker-1.0.0/bridge_checker/fetch.py +37 -0
- bridge_checker-1.0.0/bridge_checker/headless.py +114 -0
- bridge_checker-1.0.0/bridge_checker/models.py +52 -0
- bridge_checker-1.0.0/bridge_checker/parser.py +58 -0
- bridge_checker-1.0.0/bridge_checker/tui.py +246 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/PKG-INFO +173 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/SOURCES.txt +19 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/dependency_links.txt +1 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/entry_points.txt +2 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/requires.txt +6 -0
- bridge_checker-1.0.0/bridge_checker.egg-info/top_level.txt +1 -0
- bridge_checker-1.0.0/pyproject.toml +38 -0
- bridge_checker-1.0.0/setup.cfg +4 -0
- bridge_checker-1.0.0/tests/test_parser.py +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridge-checker
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fast, concurrent OBFS4 Tor bridge reachability checker with a terminal UI
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: tor,obfs4,bridges,censorship-circumvention,tui
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: textual>=0.60
|
|
22
|
+
Requires-Dist: rich>=13.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# bridge-checker
|
|
29
|
+
|
|
30
|
+
A fast, concurrent reachability checker for [Tor](https://www.torproject.org/) **OBFS4
|
|
31
|
+
bridges**, with an interactive terminal UI.
|
|
32
|
+
|
|
33
|
+
Bridges are unlisted Tor relays that help people get online in places where Tor
|
|
34
|
+
is blocked or throttled. Public bridge lists rot quickly — many entries go
|
|
35
|
+
offline — so this tool re-tests a list and tells you which bridges are
|
|
36
|
+
actually up right now.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
## What it does
|
|
42
|
+
|
|
43
|
+
- Parses a plain-text list of `obfs4 ...` bridge lines (from a file or a URL).
|
|
44
|
+
- Opens a raw TCP connection to each `host:port` concurrently (default: 150 at
|
|
45
|
+
once) and records whether it's reachable and how long it took.
|
|
46
|
+
- Streams results into a live terminal UI, or runs headlessly for scripts/CI.
|
|
47
|
+
- Writes the working (and, optionally, failed) bridges back out to plain
|
|
48
|
+
bridge-list files you can drop straight into `torrc`.
|
|
49
|
+
|
|
50
|
+
**Note on what "reachable" means:** this only proves the host is accepting TCP
|
|
51
|
+
connections on that port — it does **not** perform the actual obfs4
|
|
52
|
+
handshake, which requires the real Tor/obfs4proxy stack. A bridge that passes
|
|
53
|
+
this check is very likely a live relay, but a full Tor connection is the only
|
|
54
|
+
true test.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -r requirements.txt
|
|
60
|
+
pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+. Only dependencies are [`textual`](https://github.com/Textualize/textual)
|
|
64
|
+
(the TUI) and [`rich`](https://github.com/Textualize/rich) (headless progress output).
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
### Interactive TUI (default)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bridge-checker
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With no arguments it fetches a default public bridge list and drops you into
|
|
75
|
+
the TUI. Point it at your own list instead:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bridge-checker path/to/bridges.txt
|
|
79
|
+
bridge-checker https://example.com/my-bridges.txt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Keys: `s` start/refresh, `w` save working bridges to disk, `q` quit. You can
|
|
83
|
+
also change the source in the input field and click **Start**.
|
|
84
|
+
|
|
85
|
+
### Headless / scripting mode
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bridge-checker bridges.txt --no-tui --output working.txt --failed failed.txt
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Loading bridges from bridges.txt …
|
|
93
|
+
Found 214 bridge(s). Checking with concurrency=150, timeout=4.0s …
|
|
94
|
+
|
|
95
|
+
✔ 203.0.113.4:443 212 ms
|
|
96
|
+
✘ 203.0.113.9:8443
|
|
97
|
+
...
|
|
98
|
+
checking ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 214/214 up 137 down 77 0:00:06
|
|
99
|
+
|
|
100
|
+
Results
|
|
101
|
+
Total checked : 214
|
|
102
|
+
Working : 137
|
|
103
|
+
Failed : 77
|
|
104
|
+
Time elapsed : 6.1s (35.1 bridges/s)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### All options
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
usage: bridge-checker [-h] [--workers CONCURRENCY] [--timeout TIMEOUT]
|
|
111
|
+
[--output OUTPUT] [--failed FAILED] [--no-tui] [--quiet]
|
|
112
|
+
[--version]
|
|
113
|
+
[source]
|
|
114
|
+
|
|
115
|
+
positional arguments:
|
|
116
|
+
source Path to a local bridge list, or a URL to fetch one
|
|
117
|
+
from
|
|
118
|
+
|
|
119
|
+
options:
|
|
120
|
+
--workers, -w Number of concurrent connection attempts (default: 150)
|
|
121
|
+
--timeout, -t Per-connection timeout in seconds (default: 4.0)
|
|
122
|
+
--output, -o Where to write working bridges
|
|
123
|
+
--failed, -f Also write failed bridges to this file
|
|
124
|
+
--no-tui Run headlessly instead of the interactive TUI
|
|
125
|
+
--quiet, -q Headless mode only: summary output only
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Project layout
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
bridge_checker/
|
|
132
|
+
models.py # Bridge / BridgeResult / RunStats dataclasses
|
|
133
|
+
parser.py # obfs4 line parsing
|
|
134
|
+
fetch.py # load a bridge list from a file or URL
|
|
135
|
+
checker.py # async concurrent TCP reachability checks
|
|
136
|
+
tui.py # Textual-based interactive UI
|
|
137
|
+
headless.py # rich-based non-interactive mode
|
|
138
|
+
cli.py # argument parsing / entry point
|
|
139
|
+
tests/
|
|
140
|
+
test_parser.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install -e ".[dev]"
|
|
147
|
+
pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Releasing
|
|
151
|
+
|
|
152
|
+
`.github/workflows/publish.yml` builds and publishes the package to PyPI
|
|
153
|
+
whenever a tag matching `v*.*.*` is pushed (e.g. `v1.0.1`), or when run
|
|
154
|
+
manually from the Actions tab. It uses
|
|
155
|
+
[PyPI's trusted publishing](https://docs.pypi.org/trusted-publishers/) via
|
|
156
|
+
OIDC, so no API token/secret is needed — just:
|
|
157
|
+
|
|
158
|
+
1. Create a `pypi` environment on the PyPI project page and add this
|
|
159
|
+
repository as a trusted publisher, pointing at the `publish.yml` workflow.
|
|
160
|
+
2. Bump the `version` in `pyproject.toml`.
|
|
161
|
+
3. Push a matching tag: `git tag v1.0.1 && git push origin v1.0.1`.
|
|
162
|
+
|
|
163
|
+
A separate `.github/workflows/ci.yml` runs the test suite on every push and
|
|
164
|
+
pull request.
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
Issues and pull requests are welcome. Please keep changes small and covered
|
|
169
|
+
by a test where practical.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# bridge-checker
|
|
2
|
+
|
|
3
|
+
A fast, concurrent reachability checker for [Tor](https://www.torproject.org/) **OBFS4
|
|
4
|
+
bridges**, with an interactive terminal UI.
|
|
5
|
+
|
|
6
|
+
Bridges are unlisted Tor relays that help people get online in places where Tor
|
|
7
|
+
is blocked or throttled. Public bridge lists rot quickly — many entries go
|
|
8
|
+
offline — so this tool re-tests a list and tells you which bridges are
|
|
9
|
+
actually up right now.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- Parses a plain-text list of `obfs4 ...` bridge lines (from a file or a URL).
|
|
17
|
+
- Opens a raw TCP connection to each `host:port` concurrently (default: 150 at
|
|
18
|
+
once) and records whether it's reachable and how long it took.
|
|
19
|
+
- Streams results into a live terminal UI, or runs headlessly for scripts/CI.
|
|
20
|
+
- Writes the working (and, optionally, failed) bridges back out to plain
|
|
21
|
+
bridge-list files you can drop straight into `torrc`.
|
|
22
|
+
|
|
23
|
+
**Note on what "reachable" means:** this only proves the host is accepting TCP
|
|
24
|
+
connections on that port — it does **not** perform the actual obfs4
|
|
25
|
+
handshake, which requires the real Tor/obfs4proxy stack. A bridge that passes
|
|
26
|
+
this check is very likely a live relay, but a full Tor connection is the only
|
|
27
|
+
true test.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -r requirements.txt
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10+. Only dependencies are [`textual`](https://github.com/Textualize/textual)
|
|
37
|
+
(the TUI) and [`rich`](https://github.com/Textualize/rich) (headless progress output).
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
### Interactive TUI (default)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bridge-checker
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
With no arguments it fetches a default public bridge list and drops you into
|
|
48
|
+
the TUI. Point it at your own list instead:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bridge-checker path/to/bridges.txt
|
|
52
|
+
bridge-checker https://example.com/my-bridges.txt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Keys: `s` start/refresh, `w` save working bridges to disk, `q` quit. You can
|
|
56
|
+
also change the source in the input field and click **Start**.
|
|
57
|
+
|
|
58
|
+
### Headless / scripting mode
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bridge-checker bridges.txt --no-tui --output working.txt --failed failed.txt
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Loading bridges from bridges.txt …
|
|
66
|
+
Found 214 bridge(s). Checking with concurrency=150, timeout=4.0s …
|
|
67
|
+
|
|
68
|
+
✔ 203.0.113.4:443 212 ms
|
|
69
|
+
✘ 203.0.113.9:8443
|
|
70
|
+
...
|
|
71
|
+
checking ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 214/214 up 137 down 77 0:00:06
|
|
72
|
+
|
|
73
|
+
Results
|
|
74
|
+
Total checked : 214
|
|
75
|
+
Working : 137
|
|
76
|
+
Failed : 77
|
|
77
|
+
Time elapsed : 6.1s (35.1 bridges/s)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### All options
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
usage: bridge-checker [-h] [--workers CONCURRENCY] [--timeout TIMEOUT]
|
|
84
|
+
[--output OUTPUT] [--failed FAILED] [--no-tui] [--quiet]
|
|
85
|
+
[--version]
|
|
86
|
+
[source]
|
|
87
|
+
|
|
88
|
+
positional arguments:
|
|
89
|
+
source Path to a local bridge list, or a URL to fetch one
|
|
90
|
+
from
|
|
91
|
+
|
|
92
|
+
options:
|
|
93
|
+
--workers, -w Number of concurrent connection attempts (default: 150)
|
|
94
|
+
--timeout, -t Per-connection timeout in seconds (default: 4.0)
|
|
95
|
+
--output, -o Where to write working bridges
|
|
96
|
+
--failed, -f Also write failed bridges to this file
|
|
97
|
+
--no-tui Run headlessly instead of the interactive TUI
|
|
98
|
+
--quiet, -q Headless mode only: summary output only
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Project layout
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
bridge_checker/
|
|
105
|
+
models.py # Bridge / BridgeResult / RunStats dataclasses
|
|
106
|
+
parser.py # obfs4 line parsing
|
|
107
|
+
fetch.py # load a bridge list from a file or URL
|
|
108
|
+
checker.py # async concurrent TCP reachability checks
|
|
109
|
+
tui.py # Textual-based interactive UI
|
|
110
|
+
headless.py # rich-based non-interactive mode
|
|
111
|
+
cli.py # argument parsing / entry point
|
|
112
|
+
tests/
|
|
113
|
+
test_parser.py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
`.github/workflows/publish.yml` builds and publishes the package to PyPI
|
|
126
|
+
whenever a tag matching `v*.*.*` is pushed (e.g. `v1.0.1`), or when run
|
|
127
|
+
manually from the Actions tab. It uses
|
|
128
|
+
[PyPI's trusted publishing](https://docs.pypi.org/trusted-publishers/) via
|
|
129
|
+
OIDC, so no API token/secret is needed — just:
|
|
130
|
+
|
|
131
|
+
1. Create a `pypi` environment on the PyPI project page and add this
|
|
132
|
+
repository as a trusted publisher, pointing at the `publish.yml` workflow.
|
|
133
|
+
2. Bump the `version` in `pyproject.toml`.
|
|
134
|
+
3. Push a matching tag: `git tag v1.0.1 && git push origin v1.0.1`.
|
|
135
|
+
|
|
136
|
+
A separate `.github/workflows/ci.yml` runs the test suite on every push and
|
|
137
|
+
pull request.
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
Issues and pull requests are welcome. Please keep changes small and covered
|
|
142
|
+
by a test where practical.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Concurrent reachability checks for bridges.
|
|
2
|
+
|
|
3
|
+
We only perform a bare TCP handshake to each host:port. That proves the
|
|
4
|
+
relay is online and accepting connections, which is the practical
|
|
5
|
+
definition of "reachable" without pulling in a full obfs4proxy/Tor
|
|
6
|
+
client stack. It does not verify the obfs4 handshake itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import AsyncIterator, Sequence
|
|
14
|
+
|
|
15
|
+
from .models import Bridge, BridgeResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def check_bridge(bridge: Bridge, timeout: float) -> BridgeResult:
|
|
19
|
+
start = time.monotonic()
|
|
20
|
+
try:
|
|
21
|
+
reader, writer = await asyncio.wait_for(
|
|
22
|
+
asyncio.open_connection(bridge.host, bridge.port), timeout=timeout
|
|
23
|
+
)
|
|
24
|
+
writer.close()
|
|
25
|
+
try:
|
|
26
|
+
await writer.wait_closed()
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|
|
29
|
+
return BridgeResult(bridge=bridge, ok=True, latency=time.monotonic() - start)
|
|
30
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
31
|
+
return BridgeResult(bridge=bridge, ok=False, latency=time.monotonic() - start, error=str(exc))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def check_all(
|
|
35
|
+
bridges: Sequence[Bridge],
|
|
36
|
+
*,
|
|
37
|
+
timeout: float = 4.0,
|
|
38
|
+
concurrency: int = 150,
|
|
39
|
+
) -> AsyncIterator[BridgeResult]:
|
|
40
|
+
"""Check every bridge concurrently, yielding results as they complete."""
|
|
41
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
42
|
+
|
|
43
|
+
async def _bounded(bridge: Bridge) -> BridgeResult:
|
|
44
|
+
async with semaphore:
|
|
45
|
+
return await check_bridge(bridge, timeout)
|
|
46
|
+
|
|
47
|
+
tasks = [asyncio.create_task(_bounded(b)) for b in bridges]
|
|
48
|
+
for coro in asyncio.as_completed(tasks):
|
|
49
|
+
yield await coro
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line entry point for bridge-checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .fetch import DEFAULT_BRIDGES_URL, is_url
|
|
11
|
+
from .headless import run_headless
|
|
12
|
+
from .tui import run_tui
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="bridge-checker",
|
|
18
|
+
description="Fast, concurrent OBFS4 Tor bridge reachability checker.",
|
|
19
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"source",
|
|
23
|
+
nargs="?",
|
|
24
|
+
default=DEFAULT_BRIDGES_URL,
|
|
25
|
+
help="Path to a local bridge list, or a URL to fetch one from",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--workers", "-w", type=int, default=150, dest="concurrency",
|
|
29
|
+
help="Number of concurrent connection attempts",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--timeout", "-t", type=float, default=4.0,
|
|
33
|
+
help="Per-connection timeout in seconds",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--output", "-o", default=None,
|
|
37
|
+
help="Where to write working bridges (default: <source>_working.txt)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--failed", "-f", default=None,
|
|
41
|
+
help="Also write failed bridges to this file",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--no-tui", action="store_true",
|
|
45
|
+
help="Run headlessly (plain progress output) instead of the interactive TUI",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--quiet", "-q", action="store_true",
|
|
49
|
+
help="Headless mode only: suppress per-bridge lines, show summary only",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--version", action="version", version=f"%(prog)s {__version__}",
|
|
53
|
+
)
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str] | None = None) -> int:
|
|
58
|
+
args = build_parser().parse_args(argv)
|
|
59
|
+
|
|
60
|
+
if args.no_tui:
|
|
61
|
+
stem = "bridges" if is_url(args.source) else Path(args.source).stem
|
|
62
|
+
output = Path(args.output) if args.output else Path(f"{stem}_working.txt")
|
|
63
|
+
failed = Path(args.failed) if args.failed else None
|
|
64
|
+
return run_headless(
|
|
65
|
+
args.source,
|
|
66
|
+
timeout=args.timeout,
|
|
67
|
+
concurrency=args.concurrency,
|
|
68
|
+
output=output,
|
|
69
|
+
failed_output=failed,
|
|
70
|
+
quiet=args.quiet,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
output = Path(args.output) if args.output else None
|
|
74
|
+
run_tui(args.source, timeout=args.timeout, concurrency=args.concurrency, output=output)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Loading bridge lists from a local file or a remote URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
#: The community-maintained list this project was originally built around.
|
|
10
|
+
#: Anyone can point the checker at their own list instead.
|
|
11
|
+
DEFAULT_BRIDGES_URL = (
|
|
12
|
+
"https://raw.githubusercontent.com/scriptzteam/Tor-Bridges-Collector/"
|
|
13
|
+
"refs/heads/main/bridges-obfs4"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_USER_AGENT = "bridge-checker/1.0 (+https://github.com/)"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_url(source: str) -> bool:
|
|
20
|
+
return source.startswith("http://") or source.startswith("https://")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_text(source: str, timeout: float = 15.0) -> str:
|
|
24
|
+
"""Return the raw text of a bridge list, from a file path or a URL.
|
|
25
|
+
|
|
26
|
+
Raises FileNotFoundError / urllib.error.URLError on failure so callers
|
|
27
|
+
can present a clean error message.
|
|
28
|
+
"""
|
|
29
|
+
if is_url(source):
|
|
30
|
+
req = urllib.request.Request(source, headers={"User-Agent": _USER_AGENT})
|
|
31
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
32
|
+
return resp.read().decode("utf-8", errors="replace")
|
|
33
|
+
|
|
34
|
+
path = Path(source)
|
|
35
|
+
if not path.exists():
|
|
36
|
+
raise FileNotFoundError(f"No such file: {path}")
|
|
37
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Non-interactive ("headless") checking mode, for scripts and CI.
|
|
2
|
+
|
|
3
|
+
Prints a live progress bar with `rich` and writes results to disk as
|
|
4
|
+
they come in, instead of dropping into the full Textual TUI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
15
|
+
|
|
16
|
+
from .checker import check_all
|
|
17
|
+
from .fetch import load_text
|
|
18
|
+
from .models import BridgeResult
|
|
19
|
+
from .parser import parse_text
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_headless(
|
|
25
|
+
source: str,
|
|
26
|
+
*,
|
|
27
|
+
timeout: float = 4.0,
|
|
28
|
+
concurrency: int = 150,
|
|
29
|
+
output: Path,
|
|
30
|
+
failed_output: Path | None = None,
|
|
31
|
+
quiet: bool = False,
|
|
32
|
+
) -> int:
|
|
33
|
+
"""Run a full check synchronously (wraps the async pipeline). Returns exit code."""
|
|
34
|
+
console.print(f"[bold]Loading bridges from[/bold] {source} …")
|
|
35
|
+
try:
|
|
36
|
+
text = load_text(source)
|
|
37
|
+
except Exception as exc: # noqa: BLE001
|
|
38
|
+
console.print(f"[red]Error loading bridge list:[/red] {exc}")
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
bridges, skipped = parse_text(text)
|
|
42
|
+
if skipped:
|
|
43
|
+
console.print(f"[yellow]Skipped {skipped} unrecognised line(s).[/yellow]")
|
|
44
|
+
if not bridges:
|
|
45
|
+
console.print("[red]No valid obfs4 bridges found.[/red]")
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
total = len(bridges)
|
|
49
|
+
console.print(
|
|
50
|
+
f"Found [bold]{total}[/bold] bridge(s). "
|
|
51
|
+
f"Checking with concurrency={concurrency}, timeout={timeout}s …\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
working: list[BridgeResult] = []
|
|
55
|
+
failed: list[BridgeResult] = []
|
|
56
|
+
|
|
57
|
+
out_fh = output.open("w", encoding="utf-8")
|
|
58
|
+
fail_fh = failed_output.open("w", encoding="utf-8") if failed_output else None
|
|
59
|
+
|
|
60
|
+
progress = Progress(
|
|
61
|
+
TextColumn("[progress.description]{task.description}"),
|
|
62
|
+
BarColumn(),
|
|
63
|
+
MofNCompleteColumn(),
|
|
64
|
+
TextColumn("[green]up {task.fields[ok]}[/] [red]down {task.fields[failed]}[/]"),
|
|
65
|
+
TimeElapsedColumn(),
|
|
66
|
+
console=console,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def _drive() -> None:
|
|
70
|
+
task_id = progress.add_task("checking", total=total, ok=0, failed=0)
|
|
71
|
+
async for result in check_all(bridges, timeout=timeout, concurrency=concurrency):
|
|
72
|
+
if result.ok:
|
|
73
|
+
working.append(result)
|
|
74
|
+
out_fh.write(result.bridge.raw + "\n")
|
|
75
|
+
out_fh.flush()
|
|
76
|
+
else:
|
|
77
|
+
failed.append(result)
|
|
78
|
+
if fail_fh:
|
|
79
|
+
fail_fh.write(result.bridge.raw + "\n")
|
|
80
|
+
fail_fh.flush()
|
|
81
|
+
|
|
82
|
+
progress.update(task_id, advance=1, ok=len(working), failed=len(failed))
|
|
83
|
+
if not quiet:
|
|
84
|
+
if result.ok:
|
|
85
|
+
console.print(f" [green]✔[/] {result.bridge.address:<25} {result.latency_ms:6.0f} ms")
|
|
86
|
+
else:
|
|
87
|
+
console.print(f" [red]✘[/] {result.bridge.address}")
|
|
88
|
+
|
|
89
|
+
t0 = time.monotonic()
|
|
90
|
+
try:
|
|
91
|
+
with progress:
|
|
92
|
+
asyncio.run(_drive())
|
|
93
|
+
finally:
|
|
94
|
+
out_fh.close()
|
|
95
|
+
if fail_fh:
|
|
96
|
+
fail_fh.close()
|
|
97
|
+
elapsed = time.monotonic() - t0
|
|
98
|
+
|
|
99
|
+
console.print("\n[bold]Results[/bold]")
|
|
100
|
+
console.print(f" Total checked : {total}")
|
|
101
|
+
console.print(f" [green]Working[/green] : {len(working)}")
|
|
102
|
+
console.print(f" [red]Failed[/red] : {len(failed)}")
|
|
103
|
+
rate = total / elapsed if elapsed > 0 else 0.0
|
|
104
|
+
console.print(f" Time elapsed : {elapsed:.1f}s ({rate:.1f} bridges/s)")
|
|
105
|
+
console.print(f"\n Working bridges -> [bold]{output}[/bold]")
|
|
106
|
+
if failed_output:
|
|
107
|
+
console.print(f" Failed bridges -> [bold]{failed_output}[/bold]")
|
|
108
|
+
|
|
109
|
+
if working:
|
|
110
|
+
console.print("\n[bold]Top 10 fastest:[/bold]")
|
|
111
|
+
for i, result in enumerate(sorted(working, key=lambda r: r.latency)[:10], 1):
|
|
112
|
+
console.print(f" {i:>2}. {result.bridge.address:<25} {result.latency_ms:6.0f} ms")
|
|
113
|
+
|
|
114
|
+
return 0
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Data models for OBFS4 bridge lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Bridge:
|
|
10
|
+
"""A single parsed `obfs4 ...` bridge line."""
|
|
11
|
+
|
|
12
|
+
raw: str
|
|
13
|
+
host: str
|
|
14
|
+
port: int
|
|
15
|
+
fingerprint: str
|
|
16
|
+
extra: str = ""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def address(self) -> str:
|
|
20
|
+
return f"{self.host}:{self.port}"
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
23
|
+
return self.address
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class BridgeResult:
|
|
28
|
+
"""Outcome of testing a single bridge."""
|
|
29
|
+
|
|
30
|
+
bridge: Bridge
|
|
31
|
+
ok: bool
|
|
32
|
+
latency: float = 0.0
|
|
33
|
+
error: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def latency_ms(self) -> float:
|
|
37
|
+
return self.latency * 1000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RunStats:
|
|
42
|
+
"""Aggregate stats for a whole checking run."""
|
|
43
|
+
|
|
44
|
+
total: int = 0
|
|
45
|
+
done: int = 0
|
|
46
|
+
ok: int = 0
|
|
47
|
+
failed: int = 0
|
|
48
|
+
started_at: float = field(default=0.0)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def pct(self) -> float:
|
|
52
|
+
return (self.done / self.total * 100) if self.total else 0.0
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Parsing of obfs4 bridge lines.
|
|
2
|
+
|
|
3
|
+
A typical line looks like:
|
|
4
|
+
|
|
5
|
+
obfs4 192.0.2.1:443 0123456789ABCDEF0123456789ABCDEF01234567 cert=... iat-mode=0
|
|
6
|
+
|
|
7
|
+
IPv6 hosts are also supported, e.g. ``obfs4 [2001:db8::1]:443 ...``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .models import Bridge
|
|
15
|
+
|
|
16
|
+
_BRIDGE_RE = re.compile(
|
|
17
|
+
r"^obfs4\s+"
|
|
18
|
+
r"(\[?[\w:.]+\]?):(\d+)" # host : port
|
|
19
|
+
r"\s+([0-9A-Fa-f]{40})" # 40-hex fingerprint
|
|
20
|
+
r"(.*)", # cert= iat-mode= ...
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_line(line: str) -> Bridge | None:
|
|
26
|
+
"""Parse a single line, returning ``None`` if it isn't a valid bridge."""
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if not line or line.startswith("#"):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
match = _BRIDGE_RE.match(line)
|
|
32
|
+
if not match:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
host, port_str, fingerprint, extra = match.groups()
|
|
36
|
+
host = host.strip("[]")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
port = int(port_str)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
if not (1 <= port <= 65535):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
return Bridge(raw=line, host=host, port=port, fingerprint=fingerprint, extra=extra.strip())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_text(text: str) -> tuple[list[Bridge], int]:
|
|
49
|
+
"""Parse a whole bridge list. Returns ``(bridges, skipped_line_count)``."""
|
|
50
|
+
bridges: list[Bridge] = []
|
|
51
|
+
skipped = 0
|
|
52
|
+
for line in text.splitlines():
|
|
53
|
+
bridge = parse_line(line)
|
|
54
|
+
if bridge is not None:
|
|
55
|
+
bridges.append(bridge)
|
|
56
|
+
elif line.strip() and not line.strip().startswith("#"):
|
|
57
|
+
skipped += 1
|
|
58
|
+
return bridges, skipped
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Interactive terminal UI for the bridge checker, built with Textual."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.reactive import reactive
|
|
13
|
+
from textual.widgets import (
|
|
14
|
+
Button,
|
|
15
|
+
DataTable,
|
|
16
|
+
Footer,
|
|
17
|
+
Header,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
ProgressBar,
|
|
21
|
+
Static,
|
|
22
|
+
)
|
|
23
|
+
from textual.worker import Worker, WorkerState
|
|
24
|
+
|
|
25
|
+
from .checker import check_all
|
|
26
|
+
from .fetch import DEFAULT_BRIDGES_URL, load_text
|
|
27
|
+
from .models import Bridge, BridgeResult
|
|
28
|
+
from .parser import parse_text
|
|
29
|
+
|
|
30
|
+
STATUS_PENDING = "…"
|
|
31
|
+
STATUS_OK = "[green]✔ up[/]"
|
|
32
|
+
STATUS_FAIL = "[red]✘ down[/]"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StatsBar(Static):
|
|
36
|
+
"""One-line summary of the current run."""
|
|
37
|
+
|
|
38
|
+
total: reactive[int] = reactive(0)
|
|
39
|
+
done: reactive[int] = reactive(0)
|
|
40
|
+
ok: reactive[int] = reactive(0)
|
|
41
|
+
failed: reactive[int] = reactive(0)
|
|
42
|
+
elapsed: reactive[float] = reactive(0.0)
|
|
43
|
+
|
|
44
|
+
def render(self) -> str:
|
|
45
|
+
rate = self.done / self.elapsed if self.elapsed > 0 else 0.0
|
|
46
|
+
return (
|
|
47
|
+
f"checked [b]{self.done}[/]/{self.total} "
|
|
48
|
+
f"[green]up {self.ok}[/] [red]down {self.failed}[/] "
|
|
49
|
+
f"{self.elapsed:.1f}s {rate:.1f} bridges/s"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BridgeCheckerApp(App):
|
|
54
|
+
"""A small TUI for testing OBFS4 bridge reachability."""
|
|
55
|
+
|
|
56
|
+
CSS = """
|
|
57
|
+
#controls {
|
|
58
|
+
height: auto;
|
|
59
|
+
padding: 1 1 0 1;
|
|
60
|
+
}
|
|
61
|
+
#source {
|
|
62
|
+
width: 1fr;
|
|
63
|
+
margin-right: 1;
|
|
64
|
+
}
|
|
65
|
+
#controls Button {
|
|
66
|
+
margin-right: 1;
|
|
67
|
+
}
|
|
68
|
+
#progress {
|
|
69
|
+
padding: 0 1;
|
|
70
|
+
height: auto;
|
|
71
|
+
}
|
|
72
|
+
StatsBar {
|
|
73
|
+
padding: 0 1 1 1;
|
|
74
|
+
color: $text-muted;
|
|
75
|
+
}
|
|
76
|
+
DataTable {
|
|
77
|
+
height: 1fr;
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
BINDINGS = [
|
|
82
|
+
Binding("s", "start", "Start / Refresh"),
|
|
83
|
+
Binding("w", "save", "Save working list"),
|
|
84
|
+
Binding("q", "quit", "Quit"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
source: str = DEFAULT_BRIDGES_URL,
|
|
90
|
+
*,
|
|
91
|
+
timeout: float = 4.0,
|
|
92
|
+
concurrency: int = 150,
|
|
93
|
+
output: Path | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
super().__init__()
|
|
96
|
+
self._source = source
|
|
97
|
+
self._timeout = timeout
|
|
98
|
+
self._concurrency = concurrency
|
|
99
|
+
self._output = output
|
|
100
|
+
self._bridges: list[Bridge] = []
|
|
101
|
+
self._results: dict[str, BridgeResult] = {}
|
|
102
|
+
self._row_keys: dict[str, object] = {}
|
|
103
|
+
self._worker: Worker | None = None
|
|
104
|
+
self._status_col = None
|
|
105
|
+
self._latency_col = None
|
|
106
|
+
|
|
107
|
+
def compose(self) -> ComposeResult:
|
|
108
|
+
yield Header(show_clock=True)
|
|
109
|
+
with Vertical():
|
|
110
|
+
with Horizontal(id="controls"):
|
|
111
|
+
yield Input(value=self._source, placeholder="Bridge file path or URL", id="source")
|
|
112
|
+
yield Button("Start", id="start", variant="primary")
|
|
113
|
+
yield Button("Save working", id="save")
|
|
114
|
+
with Horizontal(id="progress"):
|
|
115
|
+
yield ProgressBar(id="bar", show_eta=False)
|
|
116
|
+
yield StatsBar()
|
|
117
|
+
yield DataTable(id="table", zebra_stripes=True)
|
|
118
|
+
yield Footer()
|
|
119
|
+
|
|
120
|
+
def on_mount(self) -> None:
|
|
121
|
+
self.title = "OBFS4 Bridge Checker"
|
|
122
|
+
self.sub_title = "press s to start"
|
|
123
|
+
table = self.query_one("#table", DataTable)
|
|
124
|
+
cols = table.add_columns("Address", "Fingerprint", "Status", "Latency")
|
|
125
|
+
self._status_col, self._latency_col = cols[2], cols[3]
|
|
126
|
+
|
|
127
|
+
# -- actions ---------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def action_start(self) -> None:
|
|
130
|
+
self._run_check()
|
|
131
|
+
|
|
132
|
+
def action_save(self) -> None:
|
|
133
|
+
self._save_working()
|
|
134
|
+
|
|
135
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
136
|
+
if event.button.id == "start":
|
|
137
|
+
self._run_check()
|
|
138
|
+
elif event.button.id == "save":
|
|
139
|
+
self._save_working()
|
|
140
|
+
|
|
141
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
142
|
+
self._run_check()
|
|
143
|
+
|
|
144
|
+
# -- core logic --------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _run_check(self) -> None:
|
|
147
|
+
if self._worker is not None and self._worker.state == WorkerState.RUNNING:
|
|
148
|
+
self.notify("A check is already running.", severity="warning")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
source = self.query_one("#source", Input).value.strip() or self._source
|
|
152
|
+
self._source = source
|
|
153
|
+
self._worker = self._load_and_check(source)
|
|
154
|
+
|
|
155
|
+
@work(exclusive=True, thread=False)
|
|
156
|
+
async def _load_and_check(self, source: str) -> None:
|
|
157
|
+
table = self.query_one("#table", DataTable)
|
|
158
|
+
stats = self.query_one(StatsBar)
|
|
159
|
+
bar = self.query_one("#bar", ProgressBar)
|
|
160
|
+
|
|
161
|
+
table.clear()
|
|
162
|
+
self._results.clear()
|
|
163
|
+
self._row_keys.clear()
|
|
164
|
+
self.sub_title = f"loading {source} …"
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
text = await self.run_worker_io(source)
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
self.notify(f"Failed to load bridge list: {exc}", severity="error", timeout=8)
|
|
170
|
+
self.sub_title = "load failed"
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
bridges, skipped = parse_text(text)
|
|
174
|
+
self._bridges = bridges
|
|
175
|
+
if not bridges:
|
|
176
|
+
self.notify("No valid obfs4 bridges found in that source.", severity="error")
|
|
177
|
+
self.sub_title = "no bridges found"
|
|
178
|
+
return
|
|
179
|
+
if skipped:
|
|
180
|
+
self.notify(f"Skipped {skipped} unrecognised line(s).", severity="warning")
|
|
181
|
+
|
|
182
|
+
for bridge in bridges:
|
|
183
|
+
key = table.add_row(bridge.address, bridge.fingerprint[:12] + "…", STATUS_PENDING, "")
|
|
184
|
+
self._row_keys[bridge.fingerprint] = key
|
|
185
|
+
|
|
186
|
+
total = len(bridges)
|
|
187
|
+
bar.total = total
|
|
188
|
+
bar.progress = 0
|
|
189
|
+
stats.total = total
|
|
190
|
+
stats.done = 0
|
|
191
|
+
stats.ok = 0
|
|
192
|
+
stats.failed = 0
|
|
193
|
+
stats.elapsed = 0.0
|
|
194
|
+
self.sub_title = f"checking {total} bridge(s) …"
|
|
195
|
+
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
async for result in check_all(bridges, timeout=self._timeout, concurrency=self._concurrency):
|
|
198
|
+
self._results[result.bridge.fingerprint] = result
|
|
199
|
+
row_key = self._row_keys[result.bridge.fingerprint]
|
|
200
|
+
status = STATUS_OK if result.ok else STATUS_FAIL
|
|
201
|
+
latency = f"{result.latency_ms:.0f} ms" if result.ok else "-"
|
|
202
|
+
table.update_cell(row_key, self._status_col, status)
|
|
203
|
+
table.update_cell(row_key, self._latency_col, latency)
|
|
204
|
+
|
|
205
|
+
stats.done += 1
|
|
206
|
+
stats.ok += 1 if result.ok else 0
|
|
207
|
+
stats.failed += 0 if result.ok else 1
|
|
208
|
+
stats.elapsed = time.monotonic() - start
|
|
209
|
+
bar.progress = stats.done
|
|
210
|
+
|
|
211
|
+
self.sub_title = (
|
|
212
|
+
f"done — {stats.ok} up / {stats.failed} down "
|
|
213
|
+
f"({stats.elapsed:.1f}s). press w to save, s to re-run."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def run_worker_io(self, source: str) -> str:
|
|
217
|
+
"""Load bridge-list text off the event loop's thread pool."""
|
|
218
|
+
import asyncio
|
|
219
|
+
|
|
220
|
+
return await asyncio.get_running_loop().run_in_executor(None, load_text, source)
|
|
221
|
+
|
|
222
|
+
def _save_working(self) -> None:
|
|
223
|
+
working = [r.bridge.raw for r in self._results.values() if r.ok]
|
|
224
|
+
if not working:
|
|
225
|
+
self.notify("No working bridges to save yet.", severity="warning")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if self._output is not None:
|
|
229
|
+
out_path = self._output
|
|
230
|
+
else:
|
|
231
|
+
base = Path(self._source).stem if not self._source.startswith("http") else "bridges"
|
|
232
|
+
out_path = Path(f"{base}_working.txt")
|
|
233
|
+
|
|
234
|
+
out_path.write_text("\n".join(working) + "\n", encoding="utf-8")
|
|
235
|
+
self.notify(f"Saved {len(working)} working bridge(s) to {out_path}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_tui(
|
|
239
|
+
source: str = DEFAULT_BRIDGES_URL,
|
|
240
|
+
*,
|
|
241
|
+
timeout: float = 4.0,
|
|
242
|
+
concurrency: int = 150,
|
|
243
|
+
output: Path | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
app = BridgeCheckerApp(source, timeout=timeout, concurrency=concurrency, output=output)
|
|
246
|
+
app.run()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridge-checker
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fast, concurrent OBFS4 Tor bridge reachability checker with a terminal UI
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: tor,obfs4,bridges,censorship-circumvention,tui
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: textual>=0.60
|
|
22
|
+
Requires-Dist: rich>=13.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# bridge-checker
|
|
29
|
+
|
|
30
|
+
A fast, concurrent reachability checker for [Tor](https://www.torproject.org/) **OBFS4
|
|
31
|
+
bridges**, with an interactive terminal UI.
|
|
32
|
+
|
|
33
|
+
Bridges are unlisted Tor relays that help people get online in places where Tor
|
|
34
|
+
is blocked or throttled. Public bridge lists rot quickly — many entries go
|
|
35
|
+
offline — so this tool re-tests a list and tells you which bridges are
|
|
36
|
+
actually up right now.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
## What it does
|
|
42
|
+
|
|
43
|
+
- Parses a plain-text list of `obfs4 ...` bridge lines (from a file or a URL).
|
|
44
|
+
- Opens a raw TCP connection to each `host:port` concurrently (default: 150 at
|
|
45
|
+
once) and records whether it's reachable and how long it took.
|
|
46
|
+
- Streams results into a live terminal UI, or runs headlessly for scripts/CI.
|
|
47
|
+
- Writes the working (and, optionally, failed) bridges back out to plain
|
|
48
|
+
bridge-list files you can drop straight into `torrc`.
|
|
49
|
+
|
|
50
|
+
**Note on what "reachable" means:** this only proves the host is accepting TCP
|
|
51
|
+
connections on that port — it does **not** perform the actual obfs4
|
|
52
|
+
handshake, which requires the real Tor/obfs4proxy stack. A bridge that passes
|
|
53
|
+
this check is very likely a live relay, but a full Tor connection is the only
|
|
54
|
+
true test.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install -r requirements.txt
|
|
60
|
+
pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+. Only dependencies are [`textual`](https://github.com/Textualize/textual)
|
|
64
|
+
(the TUI) and [`rich`](https://github.com/Textualize/rich) (headless progress output).
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
### Interactive TUI (default)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bridge-checker
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With no arguments it fetches a default public bridge list and drops you into
|
|
75
|
+
the TUI. Point it at your own list instead:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bridge-checker path/to/bridges.txt
|
|
79
|
+
bridge-checker https://example.com/my-bridges.txt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Keys: `s` start/refresh, `w` save working bridges to disk, `q` quit. You can
|
|
83
|
+
also change the source in the input field and click **Start**.
|
|
84
|
+
|
|
85
|
+
### Headless / scripting mode
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bridge-checker bridges.txt --no-tui --output working.txt --failed failed.txt
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Loading bridges from bridges.txt …
|
|
93
|
+
Found 214 bridge(s). Checking with concurrency=150, timeout=4.0s …
|
|
94
|
+
|
|
95
|
+
✔ 203.0.113.4:443 212 ms
|
|
96
|
+
✘ 203.0.113.9:8443
|
|
97
|
+
...
|
|
98
|
+
checking ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 214/214 up 137 down 77 0:00:06
|
|
99
|
+
|
|
100
|
+
Results
|
|
101
|
+
Total checked : 214
|
|
102
|
+
Working : 137
|
|
103
|
+
Failed : 77
|
|
104
|
+
Time elapsed : 6.1s (35.1 bridges/s)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### All options
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
usage: bridge-checker [-h] [--workers CONCURRENCY] [--timeout TIMEOUT]
|
|
111
|
+
[--output OUTPUT] [--failed FAILED] [--no-tui] [--quiet]
|
|
112
|
+
[--version]
|
|
113
|
+
[source]
|
|
114
|
+
|
|
115
|
+
positional arguments:
|
|
116
|
+
source Path to a local bridge list, or a URL to fetch one
|
|
117
|
+
from
|
|
118
|
+
|
|
119
|
+
options:
|
|
120
|
+
--workers, -w Number of concurrent connection attempts (default: 150)
|
|
121
|
+
--timeout, -t Per-connection timeout in seconds (default: 4.0)
|
|
122
|
+
--output, -o Where to write working bridges
|
|
123
|
+
--failed, -f Also write failed bridges to this file
|
|
124
|
+
--no-tui Run headlessly instead of the interactive TUI
|
|
125
|
+
--quiet, -q Headless mode only: summary output only
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Project layout
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
bridge_checker/
|
|
132
|
+
models.py # Bridge / BridgeResult / RunStats dataclasses
|
|
133
|
+
parser.py # obfs4 line parsing
|
|
134
|
+
fetch.py # load a bridge list from a file or URL
|
|
135
|
+
checker.py # async concurrent TCP reachability checks
|
|
136
|
+
tui.py # Textual-based interactive UI
|
|
137
|
+
headless.py # rich-based non-interactive mode
|
|
138
|
+
cli.py # argument parsing / entry point
|
|
139
|
+
tests/
|
|
140
|
+
test_parser.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install -e ".[dev]"
|
|
147
|
+
pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Releasing
|
|
151
|
+
|
|
152
|
+
`.github/workflows/publish.yml` builds and publishes the package to PyPI
|
|
153
|
+
whenever a tag matching `v*.*.*` is pushed (e.g. `v1.0.1`), or when run
|
|
154
|
+
manually from the Actions tab. It uses
|
|
155
|
+
[PyPI's trusted publishing](https://docs.pypi.org/trusted-publishers/) via
|
|
156
|
+
OIDC, so no API token/secret is needed — just:
|
|
157
|
+
|
|
158
|
+
1. Create a `pypi` environment on the PyPI project page and add this
|
|
159
|
+
repository as a trusted publisher, pointing at the `publish.yml` workflow.
|
|
160
|
+
2. Bump the `version` in `pyproject.toml`.
|
|
161
|
+
3. Push a matching tag: `git tag v1.0.1 && git push origin v1.0.1`.
|
|
162
|
+
|
|
163
|
+
A separate `.github/workflows/ci.yml` runs the test suite on every push and
|
|
164
|
+
pull request.
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
Issues and pull requests are welcome. Please keep changes small and covered
|
|
169
|
+
by a test where practical.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
bridge_checker/__init__.py
|
|
5
|
+
bridge_checker/__main__.py
|
|
6
|
+
bridge_checker/checker.py
|
|
7
|
+
bridge_checker/cli.py
|
|
8
|
+
bridge_checker/fetch.py
|
|
9
|
+
bridge_checker/headless.py
|
|
10
|
+
bridge_checker/models.py
|
|
11
|
+
bridge_checker/parser.py
|
|
12
|
+
bridge_checker/tui.py
|
|
13
|
+
bridge_checker.egg-info/PKG-INFO
|
|
14
|
+
bridge_checker.egg-info/SOURCES.txt
|
|
15
|
+
bridge_checker.egg-info/dependency_links.txt
|
|
16
|
+
bridge_checker.egg-info/entry_points.txt
|
|
17
|
+
bridge_checker.egg-info/requires.txt
|
|
18
|
+
bridge_checker.egg-info/top_level.txt
|
|
19
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bridge_checker
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bridge-checker"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Fast, concurrent OBFS4 Tor bridge reachability checker with a terminal UI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["tor", "obfs4", "bridges", "censorship-circumvention", "tui"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Internet",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"textual>=0.60",
|
|
28
|
+
"rich>=13.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
bridge-checker = "bridge_checker.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["bridge_checker*"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from bridge_checker.parser import parse_line, parse_text
|
|
2
|
+
|
|
3
|
+
FP = "0123456789ABCDEF0123456789ABCDEF01234567"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_parse_valid_ipv4_line():
|
|
7
|
+
line = f"obfs4 192.0.2.1:443 {FP} cert=abc123 iat-mode=0"
|
|
8
|
+
bridge = parse_line(line)
|
|
9
|
+
assert bridge is not None
|
|
10
|
+
assert bridge.host == "192.0.2.1"
|
|
11
|
+
assert bridge.port == 443
|
|
12
|
+
assert bridge.fingerprint == FP
|
|
13
|
+
assert bridge.address == "192.0.2.1:443"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_valid_ipv6_line():
|
|
17
|
+
line = f"obfs4 [2001:db8::1]:443 {FP} cert=abc123"
|
|
18
|
+
bridge = parse_line(line)
|
|
19
|
+
assert bridge is not None
|
|
20
|
+
assert bridge.host == "2001:db8::1"
|
|
21
|
+
assert bridge.port == 443
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_parse_rejects_comments_and_blank_lines():
|
|
25
|
+
assert parse_line("") is None
|
|
26
|
+
assert parse_line(" ") is None
|
|
27
|
+
assert parse_line("# a comment") is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_parse_rejects_bad_port():
|
|
31
|
+
line = f"obfs4 192.0.2.1:999999 {FP} cert=abc123"
|
|
32
|
+
assert parse_line(line) is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_parse_rejects_short_fingerprint():
|
|
36
|
+
line = "obfs4 192.0.2.1:443 deadbeef cert=abc123"
|
|
37
|
+
assert parse_line(line) is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_text_counts_skipped_lines():
|
|
41
|
+
text = "\n".join(
|
|
42
|
+
[
|
|
43
|
+
f"obfs4 192.0.2.1:443 {FP} cert=abc123",
|
|
44
|
+
"not a bridge line",
|
|
45
|
+
"# comment",
|
|
46
|
+
"",
|
|
47
|
+
f"obfs4 192.0.2.2:8443 {FP} cert=def456",
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
bridges, skipped = parse_text(text)
|
|
51
|
+
assert len(bridges) == 2
|
|
52
|
+
assert skipped == 1
|