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.
@@ -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
+ ![status](https://img.shields.io/badge/status-beta-yellow)
39
+ ![license](https://img.shields.io/badge/license-MIT-blue)
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
+ ![status](https://img.shields.io/badge/status-beta-yellow)
12
+ ![license](https://img.shields.io/badge/license-MIT-blue)
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,3 @@
1
+ """bridge-checker: a fast, concurrent OBFS4 Tor bridge reachability checker."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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
+ ![status](https://img.shields.io/badge/status-beta-yellow)
39
+ ![license](https://img.shields.io/badge/license-MIT-blue)
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,2 @@
1
+ [console_scripts]
2
+ bridge-checker = bridge_checker.cli:main
@@ -0,0 +1,6 @@
1
+ textual>=0.60
2
+ rich>=13.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-asyncio>=0.23
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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