├── .github └── workflows │ └── build.yml ├── .gitignore ├── README.md ├── build.sh ├── pyproject.toml ├── setup.cfg └── src └── tor_relay_scanner ├── __init__.py ├── __main__.py └── scanner.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build tor-relay-scanner 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**' 7 | workflow_dispatch: 8 | 9 | env: 10 | PYTHON_URL: https://www.python.org/ftp/python/3.8.10/python-3.8.10.exe 11 | PYTHON_NAME: python-3.8.10.exe 12 | PYTHON_SHA256: ad07633a1f0cd795f3bf9da33729f662281df196b4567fa795829f3bb38a30ac 13 | WINEARCH: win32 14 | 15 | jobs: 16 | build_linux: 17 | name: Build Python pyz file 18 | runs-on: ubuntu-20.04 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Declare short commit variable 23 | id: vars 24 | run: | 25 | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 26 | 27 | - name: Build pyz file 28 | run: | 29 | ./build.sh 30 | mv torparse.pyz tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.pyz 31 | 32 | - name: Upload output file x86 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.pyz 36 | path: tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.pyz 37 | 38 | build_windows: 39 | name: Build Windows exe file 40 | runs-on: ubuntu-20.04 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Declare short commit variable 45 | id: vars 46 | run: | 47 | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 48 | 49 | - name: Install Wine 50 | run: > 51 | sudo rm /var/lib/man-db/auto-update && 52 | sudo sed -i 's/yes/no/g' /etc/initramfs-tools/update-initramfs.conf && 53 | sudo apt-add-repository -y ppa:ondrej/php && 54 | sudo eatmydata apt install ppa-purge && 55 | sudo eatmydata ppa-purge -y ppa:ondrej/php && 56 | sudo dpkg --add-architecture i386 && sudo apt update && 57 | sudo DEBIAN_FRONTEND=noninteractive eatmydata 58 | apt install -y --no-install-recommends wine-stable wine32 xvfb 59 | 60 | - name: Download Python from cache 61 | id: python-cache 62 | uses: actions/cache@v4 63 | with: 64 | path: ${{ env. PYTHON_NAME }} 65 | key: ${{ env. PYTHON_SHA256 }} 66 | 67 | - name: Download Python from the website 68 | if: steps.python-cache.outputs.cache-hit != 'true' 69 | run: > 70 | wget ${{ env. PYTHON_URL }} && 71 | (echo ${{ env. PYTHON_SHA256 }} ${{ env. PYTHON_NAME }} | sha256sum -c) 72 | 73 | - name: Install Python and dependencies (Windows) 74 | run: | 75 | xvfb-run wine ${{ env. PYTHON_NAME }} /quiet InstallAllUsers=1 PrependPath=1 76 | wine pip install . 77 | wine pip install pyinstaller 78 | 79 | - name: Build exe file for Windows 80 | run: | 81 | wine pyinstaller -c -F src/tor_relay_scanner/__main__.py 82 | mv dist/__main__.exe tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.exe 83 | 84 | - name: Upload output file 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.exe 88 | path: tor-relay-scanner-${{ steps.vars.outputs.sha_short }}.exe 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyz 2 | torparse/* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tor Relay Availability Checker 2 | ============================== 3 | 4 | This small script downloads all Tor Relay IP addresses from [onionoo.torproject.org](https://onionoo.torproject.org/) directly and via embedded proxies, and checks whether random Tor Relays are reachable from your Internet connection. 5 | 6 | It could be used to find working Relay in a countries with Internet censorship and blocked Tor, and use it as Bridge to connect to Tor network, bypassing standard well-known nodes embedded into Tor code. 7 | 8 | ## How to use with Tor Browser 9 | 10 | Works on Windows and Linux. Not tested on macOS. 11 | 12 | 1. Download latest version from [Releases](https://github.com/ValdikSS/tor-relay-scanner/releases) page. 13 | 2. Put the file into Tor Browser's directory. 14 | 3. **(Windows)**: Create a shortcut (link) to the file and append the following command line in shortcut settings: `-g 2 --timeout 3 --browser --start-browser` 15 | **(Linux)** : Create a shortcut to the file, launching it using `python3`, and append the following arguments: `-g 2 --timeout 3 --browser --start-browser`. 16 | The quick way to do this is to create a script with the following command: 17 | `echo -e '#!/bin/sh\nexec python3' ./tor-relay-scanner-*.pyz '-g 2 --timeout 3 --browser --start-browser' > run.sh && chmod +x run.sh` 18 | 4. From now on, launch Tor Browser using the shortcut you've created in step 3. It will scan for reachable Relays, add it to Tor Browser configuration file (prefs.js), and launch the browser. 19 | 20 | 21 | ## How to use with Tor (daemon) 22 | 23 | This utility is capable of generating `torrc` configuration file containing Bridge information. Launch it with the following arguments: 24 | 25 | `--torrc --outfile /etc/tor/bridges.conf` 26 | 27 | And append: 28 | 29 | `%include /etc/tor/bridges.conf` 30 | 31 | to the end of `/etc/tor/torrc` file to make Tor daemon load it. 32 | 33 | 34 | ## How to use as a standalone tool 35 | 36 | **Windows**: download ***.exe** file from [Releases](https://github.com/ValdikSS/tor-relay-scanner/releases) and run it in console (`start → cmd`) 37 | 38 | **Linux & macOS**: download ***.pyz** file from [Releases](https://github.com/ValdikSS/tor-relay-scanner/releases) and run it with Python 3.7+: 39 | `python3 tor-relay-scanner.pyz` 40 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python -m pip install . --target torparse 3 | find torparse -path '*/__pycache__*' -delete 4 | cp torparse/tor_relay_scanner/__main__.py torparse/ 5 | python -m zipapp -c -p '/usr/bin/env python3' torparse 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = tor_relay_scanner 3 | version = 1.0.2 4 | author = ValdikSS 5 | author_email = iam@valdikss.org.ru 6 | description = Tor Relay availability checker, for using it as a bridge in countries with censorship 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/ValdikSS/tor-relay-scanner 10 | project_urls = 11 | Bug Tracker = https://github.com/ValdikSS/tor-relay-scanner/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Operating System :: OS Independent 15 | 16 | [options] 17 | package_dir = 18 | =src 19 | packages = find: 20 | python_requires = >=3.7 21 | #install_requires = requests[socks] 22 | install_requires = 23 | requests[socks]@https://github.com/psf/requests/archive/8c86fb111b955ea76439199821973c40a70a19a2.zip ;platform_system=='Windows' 24 | requests[socks] ;platform_system!='Windows' 25 | 26 | [options.packages.find] 27 | where = src 28 | -------------------------------------------------------------------------------- /src/tor_relay_scanner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ValdikSS/tor-relay-scanner/ba991051dbbc9dce0cb8e75ca5f3e011078b8787/src/tor_relay_scanner/__init__.py -------------------------------------------------------------------------------- /src/tor_relay_scanner/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | from tor_relay_scanner import scanner 4 | 5 | if __name__ == "__main__": 6 | sys.exit(scanner.main()) 7 | -------------------------------------------------------------------------------- /src/tor_relay_scanner/scanner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import random 5 | import sys 6 | import urllib.parse 7 | import argparse 8 | import subprocess 9 | import os.path 10 | import requests 11 | 12 | DESCRIPTION = "Downloads all Tor Relay IP addresses from onionoo.torproject.org and checks whether random Relays are available." 13 | 14 | class TCPSocketConnectChecker: 15 | def __init__(self, host, port, timeout=10.0): 16 | self.host = host 17 | self.port = port 18 | self.timeout = timeout 19 | self.connection_status = None 20 | 21 | def __repr__(self): 22 | return "{}:{}".format( 23 | self.host if self.host.find(":") == -1 else "[" + self.host + "]", 24 | self.port) 25 | 26 | async def connect(self): 27 | try: 28 | # Open connection 29 | reader, writer = await asyncio.wait_for( 30 | asyncio.open_connection(self.host, self.port), self.timeout) 31 | # And close it 32 | writer.close() 33 | await writer.wait_closed() 34 | self.connection_status = True 35 | return (True, None) 36 | except (OSError, asyncio.TimeoutError) as e: 37 | self.connection_status = False 38 | return (False, e) 39 | 40 | class TorRelayGrabber: 41 | def __init__(self, timeout=10.0, proxy=None): 42 | self.timeout = timeout 43 | self.proxy = {'https': proxy} if proxy else None 44 | 45 | def _grab(self, url): 46 | with requests.get(url, timeout=int(self.timeout), proxies=self.proxy) as r: 47 | return r.json() 48 | 49 | def grab(self, preferred_urls_list=None): 50 | BASEURL = "https://onionoo.torproject.org/details?type=relay&running=true&fields=fingerprint,or_addresses,country" 51 | # Use public CORS proxy as a regular proxy in case if onionoo.torproject.org is unreachable 52 | URLS = [BASEURL, 53 | "https://icors.vercel.app/?" + urllib.parse.quote(BASEURL), 54 | "https://github.com/ValdikSS/tor-onionoo-mirror/raw/master/details-running-relays-fingerprint-address-only.json", 55 | "https://bitbucket.org/ValdikSS/tor-onionoo-mirror/raw/master/details-running-relays-fingerprint-address-only.json"] 56 | if preferred_urls_list: 57 | for pref_url in preferred_urls_list: 58 | URLS.insert(0, pref_url) 59 | 60 | for url in URLS: 61 | try: 62 | return self._grab(url) 63 | except Exception as e: 64 | print("Can't download Tor Relay data from/via {}: {}".format( 65 | urllib.parse.urlparse(url).hostname, e 66 | ), file=sys.stderr) 67 | 68 | def grab_parse(self, preferred_urls_list=None): 69 | grabbed = self.grab(preferred_urls_list) 70 | if grabbed: 71 | grabbed = grabbed["relays"] 72 | return grabbed 73 | 74 | 75 | class TorRelay: 76 | def __init__(self, relayinfo): 77 | self.relayinfo = relayinfo 78 | self.fingerprint = relayinfo["fingerprint"] 79 | self.iptuples = self._parse_or_addresses(relayinfo["or_addresses"]) 80 | self.reachable = list() 81 | 82 | def reachables(self): 83 | r = list() 84 | for i in self.reachable: 85 | r.append("{}:{} {}".format(i[0] if i[0].find(":") == -1 else "[" + i[0] + "]", 86 | i[1], 87 | self.fingerprint,)) 88 | return r 89 | 90 | def _reachable_str(self): 91 | return "\n".join(self.reachables()) 92 | 93 | def __repr__(self): 94 | if not self.reachable: 95 | return str(self.relayinfo) 96 | return self._reachable_str() 97 | 98 | def __len__(self): 99 | return len(self.reachable) 100 | 101 | def _parse_or_addresses(self, or_addresses): 102 | ret = list() 103 | for address in or_addresses: 104 | parsed = urllib.parse.urlparse("//" + address) 105 | ret.append((parsed.hostname, parsed.port)) 106 | return ret 107 | 108 | async def check(self, timeout=10.0): 109 | for i in self.iptuples: 110 | s = TCPSocketConnectChecker(i[0], i[1], timeout=timeout) 111 | sc = await s.connect() 112 | if sc[0]: 113 | self.reachable.append(i) 114 | 115 | return bool(self.reachable) 116 | 117 | 118 | def start_browser(): 119 | browser_cmds=("Browser/start-tor-browser --detach", "Browser/firefox.exe") 120 | for cmd in browser_cmds: 121 | if os.path.exists(cmd.split(" ")[0]): 122 | subprocess.Popen(cmd.split(" ")) 123 | break 124 | 125 | 126 | def str_list_with_prefix(prefix, list_): 127 | return "\n".join(prefix + r for r in list_) 128 | 129 | 130 | def chunked_list(l, size): 131 | for i in range(0, len(l), size): 132 | yield l[i:i+size] 133 | 134 | 135 | async def main_async(args): 136 | NUM_RELAYS = args.num_relays 137 | WORKING_RELAY_NUM_GOAL = args.working_relay_num_goal 138 | TIMEOUT = args.timeout 139 | outstream = args.outfile 140 | torrc_fmt = args.torrc_fmt 141 | BRIDGE_PREFIX = "Bridge " if torrc_fmt else "" 142 | 143 | if args.prefsjs: 144 | if not os.path.isfile(args.prefsjs): 145 | print("Error: the --browser {} file does not exist!".format(args.prefsjs), file=sys.stderr) 146 | return 3 147 | 148 | print(f"Tor Relay Scanner. Will scan up to {WORKING_RELAY_NUM_GOAL}" + 149 | " working relays (or till the end)", file=sys.stderr) 150 | print("Downloading Tor Relay information from Tor Metrics…", file=sys.stderr) 151 | relays = TorRelayGrabber(timeout=TIMEOUT, proxy=args.proxy).grab_parse(args.url) 152 | if not relays: 153 | print("Tor Relay information can't be downloaded!", file=sys.stderr) 154 | return 1 155 | print("Done!", file=sys.stderr) 156 | 157 | random.shuffle(relays) 158 | 159 | if args.preferred_country: 160 | countries = {} 161 | exclude_countries = {} 162 | only_countries = {} 163 | for i, c in enumerate(args.preferred_country.split(",")): 164 | if c.startswith('!'): # exclusive countries, include only it 165 | only_countries[c.lstrip('!')] = True 166 | 167 | if c.startswith('-'): # excluded countries 168 | exclude_countries[c.lstrip('-')] = False 169 | else: 170 | # sorted countries, 171 | # only_countries also fall-thru here for sorting purposes 172 | countries[c.lstrip('!')] = i 173 | 174 | if only_countries: 175 | relays = filter(lambda x: only_countries.get(x.get("country"), False), relays) 176 | if exclude_countries or len(countries) != len(only_countries): 177 | print("Warning: you've set exclusive country(ies) with other sorted or excluded countries, using only exclusive list!", file=sys.stderr) 178 | if exclude_countries: 179 | relays = filter(lambda x: exclude_countries.get(x.get("country"), True), relays) 180 | # 1000 is just a sufficiently large number for default sorting 181 | relays = sorted(relays, key=lambda x: countries.get(x.get("country"), 1000)) 182 | 183 | if args.port: 184 | relays_new = list() 185 | for relay in relays: 186 | for ipport in TorRelay(relay).iptuples: 187 | if ipport[1] in args.port: 188 | # deep copy needed here, otherwise subsequent loop 189 | # modifies "previous" value 190 | relay_copy = relay.copy() 191 | relay_copy["or_addresses"] = ["{}:{}".format( 192 | ipport[0] if ipport[0].find(":") == -1 else "[" + ipport[0] + "]", 193 | ipport[1]) 194 | ] 195 | relays_new.append(relay_copy) 196 | relays = relays_new 197 | if not relays: 198 | print("There are no relays within specified port number constraints!", file=sys.stderr) 199 | print("Try changing port numbers.", file=sys.stderr) 200 | return 2 201 | 202 | working_relays = list() 203 | numtries = (len(relays) + NUM_RELAYS - 1) // NUM_RELAYS 204 | ntry = -1 205 | for ntry, chunk in enumerate(chunked_list(relays, NUM_RELAYS)): 206 | if len(working_relays) >= WORKING_RELAY_NUM_GOAL: 207 | break 208 | 209 | relaynum = len(chunk) 210 | test_relays = [TorRelay(r) for r in chunk] 211 | 212 | print( 213 | f"\nAttempt {ntry+1}/{numtries}, We'll test the following {relaynum} random relays:", file=sys.stderr) 214 | for relay in test_relays: 215 | print(relay, file=sys.stderr) 216 | print(file=sys.stderr) 217 | 218 | if ntry: 219 | print(f"Found {len(working_relays)} good relays so far. Test {ntry+1}/{numtries} started…", file=sys.stderr) 220 | else: 221 | print(f"Test started…", file=sys.stderr) 222 | 223 | tasks = list() 224 | for relay in test_relays: 225 | tasks.append(asyncio.create_task(relay.check(TIMEOUT))) 226 | fin = await asyncio.gather(*tasks) 227 | print(file=sys.stderr) 228 | 229 | print("The following relays are reachable this test attempt:", file=sys.stderr) 230 | for relay in test_relays: 231 | if relay: 232 | print(str_list_with_prefix(BRIDGE_PREFIX, relay.reachables()), file=outstream) 233 | if sys.stdout != outstream: 234 | print(str_list_with_prefix(BRIDGE_PREFIX, relay.reachables()), file=sys.stderr) 235 | working_relays.append(relay) 236 | if not any(test_relays): 237 | print("No relays are reachable this test attempt.", file=sys.stderr) 238 | 239 | if ntry > 1: 240 | print(file=sys.stderr) 241 | print("All reachable relays:", file=sys.stderr) 242 | for relay in working_relays: 243 | if relay: 244 | print(str_list_with_prefix(BRIDGE_PREFIX, relay.reachables()), file=sys.stderr) 245 | if not any(working_relays): 246 | print("No relays are reachable, at all.", file=sys.stderr) 247 | elif ntry == -1: 248 | print("No relays selected, nothing to test. Check your preferred-country filter and other settings.", 249 | file=sys.stderr) 250 | 251 | if any(working_relays): 252 | if torrc_fmt: 253 | print("UseBridges 1", file=outstream) 254 | if args.prefsjs: 255 | try: 256 | with open(args.prefsjs, "r+") as f: 257 | prefsjs = str() 258 | for line in f: 259 | if "torbrowser.settings.bridges." not in line: 260 | prefsjs += line 261 | # Ugly r.reachables() array flattening, as it may have more than one reachable record. 262 | for num, relay in enumerate(sum([r.reachables() for r in working_relays], [])): 263 | prefsjs += f'user_pref("torbrowser.settings.bridges.bridge_strings.{num}", "{relay}");\n' 264 | prefsjs += 'user_pref("torbrowser.settings.bridges.enabled", true);\n' 265 | prefsjs += 'user_pref("torbrowser.settings.bridges.source", 2);\n' 266 | f.seek(0) 267 | f.truncate() 268 | f.write(prefsjs) 269 | except OSError as e: 270 | print("Can't open Tor Browser configuration:", e, file=sys.stderr) 271 | 272 | if args.start_browser: 273 | start_browser() 274 | 275 | 276 | def main(): 277 | parser = argparse.ArgumentParser(description=DESCRIPTION) 278 | parser.add_argument('-n', type=int, dest='num_relays', default=30, help='The number of concurrent relays tested (default: %(default)s)') 279 | parser.add_argument('-g', '--goal', type=int, dest='working_relay_num_goal', default=5, help='Test until at least this number of working relays are found (default: %(default)s)') 280 | parser.add_argument('-c', '--preferred-country', type=str, default="", help='Preferred/excluded/exclusive country list, comma-separated. Use "-" prefix to exclude the country, "!" to use only selected country. Example: se,gb,nl,-us,-de. Example for exclusive countries: !us,!tr') 281 | parser.add_argument('--timeout', type=float, default=10.0, help='Socket connection timeout (default: %(default)s)') 282 | parser.add_argument('-o', '--outfile', type=argparse.FileType('w'), default=sys.stdout, help='Output reachable relays to file') 283 | parser.add_argument('--torrc', action='store_true', dest='torrc_fmt', help='Output reachable relays in torrc format (with "Bridge" prefix)') 284 | parser.add_argument('--proxy', type=str, help='Set proxy for onionoo information download (not for scan). Format: http://user:pass@host:port; socks5h://user:pass@host:port') 285 | parser.add_argument('--url', type=str, action='append', help='Preferred alternative URL for onionoo relay list. Could be used multiple times.') 286 | parser.add_argument('-p', type=int, dest='port', action='append', help='Scan for relays running on specified port number. Could be used multiple times.') 287 | parser.add_argument('--browser', type=str, nargs='?', metavar='/path/to/prefs.js', dest='prefsjs', 288 | const='Browser/TorBrowser/Data/Browser/profile.default/prefs.js', 289 | help='Install found relays into Tor Browser configuration file (prefs.js)') 290 | parser.add_argument('--start-browser', action='store_true', help='Launch browser after scanning') 291 | args = parser.parse_args() 292 | try: 293 | return asyncio.run(main_async(args)) 294 | except (KeyboardInterrupt, SystemExit): 295 | pass 296 | --------------------------------------------------------------------------------