├── tests ├── __init__.py ├── test_server.py └── test_changer.py ├── requirements-server.txt ├── requirements.txt ├── toripchanger ├── __init__.py ├── exceptions.py ├── server.py └── changer.py ├── MANIFEST.in ├── .dockerignore ├── .gitignore ├── Dockerfile ├── docker-compose.yaml ├── .github └── workflows │ └── main.yml ├── LICENSE ├── setup.py ├── scripts └── toripchanger_server └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-server.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.* 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.* 2 | stem==1.8.2 3 | -------------------------------------------------------------------------------- /toripchanger/__init__.py: -------------------------------------------------------------------------------- 1 | from .changer import TorIpChanger # noqa 2 | -------------------------------------------------------------------------------- /toripchanger/exceptions.py: -------------------------------------------------------------------------------- 1 | class TorIpError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include requirements-server.txt 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Repo 2 | .git 3 | 4 | # Byte-compiled / optimized / DLL files 5 | **/__pycache__/ 6 | **/*.py[cod] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # packaging directories 6 | build/ 7 | dist/ 8 | *.egg-info/ 9 | *.eggs/ 10 | *.cache/ 11 | 12 | # C extensions 13 | *.so 14 | 15 | # virtualenv folder 16 | venv/ 17 | 18 | # backup files 19 | *~ 20 | 21 | # py.test coverage 22 | .coverage 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | LABEL maintainer="Dušan Maďar" 4 | 5 | ENV APP_DIR=/usr/src/app 6 | ENV PYTHONPATH="${PYTHONPATH}:${APP_DIR}" 7 | WORKDIR $APP_DIR 8 | 9 | COPY requirements.txt requirements-server.txt ./ 10 | RUN pip install -r requirements.txt -r requirements-server.txt 11 | COPY scripts ./scripts/ 12 | COPY toripchanger ./toripchanger 13 | 14 | ENTRYPOINT ["python", "/usr/src/app/scripts/toripchanger_server"] 15 | -------------------------------------------------------------------------------- /toripchanger/server.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | 4 | def create_changeip_response(tor_ip_changer): 5 | new_ip = "" 6 | error = "" 7 | status = 200 8 | 9 | try: 10 | new_ip = tor_ip_changer.get_new_ip() 11 | except Exception as exc: 12 | error = "{}: {}".format(exc.__class__, str(exc)) 13 | status = 500 14 | 15 | response = flask.jsonify({"newIp": new_ip, "error": error}) 16 | response.status_code = status 17 | 18 | return response, status 19 | 20 | 21 | def init_server(tor_ip_changer): 22 | app = flask.Flask(__name__) 23 | 24 | @app.route("/") 25 | def index(): 26 | return "TorIpChanger Server" 27 | 28 | @app.route("/changeip/") 29 | def changeip(): 30 | return create_changeip_response(tor_ip_changer) 31 | 32 | return app 33 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | tor-proxy: 5 | image: dperson/torproxy 6 | ports: 7 | - "8118:8118" # Privoxy. 8 | - "9051:9051" # Tor Control. 9 | environment: 10 | PASSWORD: "tor" 11 | TOR_ControlPort: "0.0.0.0:9051" 12 | 13 | tor-ip-changer: 14 | build: . 15 | image: toripchanger 16 | ports: 17 | - "8080:8080" 18 | volumes: 19 | - .:/usr/src/app 20 | depends_on: 21 | - "tor-proxy" 22 | command: [ 23 | "--tor-password", "tor", 24 | "--tor-port", "9051", 25 | "--tor-address", "tor-proxy", 26 | "--new-ip-max-attempts", "100", 27 | "--reuse-threshold", "5", 28 | "--local-http-proxy", "http://tor-proxy:8118", 29 | "--server-host", "0.0.0.0", 30 | "--server-port", "8080", 31 | ] 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 3.10 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.10" 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install pytest pytest-cov 26 | pip install -r requirements.txt 27 | pip install -r requirements-server.txt 28 | 29 | - name: Test with pytest 30 | run: | 31 | pytest --cov 32 | 33 | - name: Coveralls 34 | uses: coverallsapp/github-action@v2 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dušan Maďar 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. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """TorIpChanger package installer""" 2 | 3 | 4 | from os import path 5 | from setuptools import setup 6 | 7 | 8 | def read(fname, readlines=False): 9 | with open(path.join(path.abspath(path.dirname(__file__)), fname)) as f: 10 | return f.readlines() if readlines else f.read() 11 | 12 | 13 | requirements = read("requirements.txt", True) 14 | requirements_server = read("requirements-server.txt", True) 15 | 16 | setup( 17 | version="1.3.0", 18 | name="toripchanger", 19 | url="https://github.com/DusanMadar/TorIpChanger", 20 | author="Dusan Madar", 21 | description="Python powered way to get a unique Tor IP", 22 | long_description=read("README.md"), 23 | long_description_content_type="text/markdown", 24 | keywords="change tor ip", 25 | packages=["toripchanger"], 26 | scripts=["scripts/toripchanger_server"], 27 | include_package_data=True, 28 | test_suite="tests", 29 | license="MIT", 30 | platforms="linux", 31 | python_requires=">=3.5", 32 | install_requires=requirements, 33 | tests_require=requirements + requirements_server, 34 | classifiers=[ 35 | "Development Status :: 5 - Production/Stable", 36 | "Intended Audience :: Developers", 37 | "Programming Language :: Python :: 3", 38 | "Operating System :: POSIX :: Linux", 39 | "Natural Language :: English", 40 | ], 41 | extras_require={"server": requirements_server}, 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from toripchanger import server 4 | 5 | 6 | class TestTorIpChangerServer(unittest.TestCase): 7 | def setUp(self): 8 | self.tor_ip_changer = unittest.mock.Mock() 9 | app = server.init_server(self.tor_ip_changer) 10 | app.testing = True 11 | 12 | self.client = app.test_client() 13 | 14 | def test_index(self): 15 | """ 16 | Test `/` endpoint returns a simple text message. 17 | """ 18 | r = self.client.get("/") 19 | self.assertEqual(r.status_code, 200) 20 | self.assertEqual(r.data.decode("utf-8"), "TorIpChanger Server") 21 | 22 | def test_changeip(self): 23 | """ 24 | Test `/changeip/` endpoint is capable of changing Tor' IP. 25 | """ 26 | self.tor_ip_changer.get_new_ip.return_value = "1.2.3.4" 27 | r = self.client.get("/changeip/") 28 | 29 | self.assertEqual(r.status_code, 200) 30 | self.assertEqual(r.json, {"newIp": "1.2.3.4", "error": ""}) 31 | 32 | def test_changeip_exception(self): 33 | """ 34 | Test `/changeip/` gracefully handles all exceptions. 35 | """ 36 | self.tor_ip_changer.get_new_ip.side_effect = [ValueError("message")] 37 | r = self.client.get("/changeip/") 38 | 39 | self.assertEqual(r.status_code, 500) 40 | self.assertEqual( 41 | r.json, {"error": ": message", "newIp": ""} 42 | ) 43 | -------------------------------------------------------------------------------- /scripts/toripchanger_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | import argparse 5 | import sys 6 | 7 | from toripchanger import changer 8 | try: 9 | from toripchanger import server 10 | except ImportError as exc: 11 | msg = str(exc) 12 | if 'flask' not in msg: 13 | raise 14 | 15 | sys.exit( 16 | "{}\nflask is required to to use `toripchanger_server`. Run " 17 | "'pip install toripchanger[server]' to install required dependencies".format(str(exc)) 18 | ) 19 | 20 | 21 | def parse_cmd_args(): 22 | parser = argparse.ArgumentParser( 23 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 24 | ) 25 | 26 | parser.add_argument( 27 | "--server-host", 28 | default="0.0.0.0", 29 | type=str, 30 | help="TorIpChanger server host", 31 | ) 32 | parser.add_argument( 33 | "--server-port", 34 | default=8080, 35 | type=int, 36 | help="TorIpChanger server port", 37 | ) 38 | 39 | parser.add_argument( 40 | "--reuse-threshold", 41 | default=changer.REUSE_THRESHOLD, 42 | type=int, 43 | help="Number of IPs to use before reusing the current one", 44 | ) 45 | parser.add_argument( 46 | "--local-http-proxy", 47 | default=changer.LOCAL_HTTP_PROXY, 48 | type=str, 49 | help="Local proxy IP and port", 50 | ) 51 | parser.add_argument( 52 | "--tor-password", 53 | default="\"{}\"".format(changer.TOR_PASSWORD), 54 | type=str, 55 | help="Tor controller password", 56 | ) 57 | parser.add_argument( 58 | "--tor-address", 59 | default=changer.TOR_ADDRESS, 60 | type=str, 61 | help="IP address or resolvable hostname of the Tor controller", 62 | ) 63 | parser.add_argument( 64 | "--tor-port", 65 | default=changer.TOR_PORT, 66 | type=int, 67 | help="Port number of the Tor controller", 68 | ) 69 | parser.add_argument( 70 | "--new-ip-max-attempts", 71 | default=changer.NEW_IP_MAX_ATTEMPTS, 72 | type=int, 73 | help="Get new IP attempts limit", 74 | ) 75 | 76 | return parser.parse_args() 77 | 78 | 79 | def main(): 80 | args = parse_cmd_args() 81 | 82 | tor_ip_changer = changer.TorIpChanger( 83 | reuse_threshold=args.reuse_threshold, 84 | local_http_proxy=args.local_http_proxy, 85 | tor_password=args.tor_password, 86 | tor_address=args.tor_address, 87 | tor_port=args.tor_port, 88 | new_ip_max_attempts=args.new_ip_max_attempts 89 | ) 90 | 91 | tor_ip_changer_server = server.init_server(tor_ip_changer) 92 | tor_ip_changer_server.run(host=args.server_host, port=args.server_port) 93 | 94 | 95 | if __name__ == "__main__": 96 | main() 97 | -------------------------------------------------------------------------------- /toripchanger/changer.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import socket 3 | 4 | from time import sleep 5 | 6 | from requests import get 7 | from requests.exceptions import RequestException 8 | from stem import Signal 9 | from stem.control import Controller 10 | 11 | from toripchanger.exceptions import TorIpError 12 | 13 | # Default settings. 14 | REUSE_THRESHOLD = 1 15 | LOCAL_HTTP_PROXY = "http://127.0.0.1:8118" 16 | NEW_IP_MAX_ATTEMPTS = 10 17 | TOR_PASSWORD = "" 18 | TOR_ADDRESS = "127.0.0.1" 19 | TOR_PORT = 9051 20 | POST_NEW_IP_SLEEP = 1.0 21 | 22 | # Service to get current IP. 23 | ICANHAZIP_HTTPS = "https://icanhazip.com/" 24 | 25 | 26 | class TorIpChanger: 27 | def __init__( 28 | self, 29 | reuse_threshold=REUSE_THRESHOLD, 30 | local_http_proxy=LOCAL_HTTP_PROXY, 31 | tor_password=TOR_PASSWORD, 32 | tor_address=TOR_ADDRESS, 33 | tor_port=TOR_PORT, 34 | new_ip_max_attempts=NEW_IP_MAX_ATTEMPTS, 35 | post_new_ip_sleep=POST_NEW_IP_SLEEP, 36 | ): 37 | """ 38 | TorIpChanger - make sure requesting a new Tor IP address really does 39 | return a new (different) IP. 40 | 41 | The 'reuse_threshold' argument specifies the number of unique Tor IPs 42 | kept in the 'used_ips' list which works as a FIFO queue. 43 | 44 | When 'reuse_threshold' is set to 0 then none of the already used Tor IP 45 | addresses can be reused. 46 | When 'reuse_threshold' is set to 1 (which is default) then the current 47 | Tor IP address can be reused after one other IP was used. 48 | If 'reuse_threshold' is set to, for example, 5 then the current Tor IP 49 | address can be reused after 5 other Tor IPs were used. 50 | 51 | :argument reuse_threshold: IPs to use before reusing the current one 52 | :type reuse_threshold: int 53 | :argument local_http_proxy: local proxy IP and port 54 | :type local_http_proxy: str 55 | :argument tor_password: Tor password 56 | :type tor_password: str 57 | :argument tor_address: IP address or resolvable hostname of the Tor controller 58 | :type tor_address: str 59 | :argument tor_port: port number of the Tor controller 60 | :type tor_port: int 61 | :argument new_ip_max_attempts: get new IP attempts limit 62 | :type new_ip_max_attempts: int 63 | :argument post_new_ip_sleep: how long to wait after requesting a new IP 64 | :type post_new_ip_sleep: float 65 | """ 66 | self.reuse_threshold = reuse_threshold 67 | self.local_http_proxy = local_http_proxy 68 | self.tor_password = tor_password 69 | self.tor_address = socket.gethostbyname(tor_address) 70 | self.tor_port = tor_port 71 | self.new_ip_max_attempts = new_ip_max_attempts 72 | self.post_new_ip_sleep = post_new_ip_sleep 73 | 74 | self.used_ips = [] # We cannot use set() because order matters. 75 | 76 | @property 77 | def real_ip(self): 78 | """ 79 | The actual public IP of this host. 80 | """ 81 | if not hasattr(self, "_real_ip"): 82 | response = get(ICANHAZIP_HTTPS) 83 | self._real_ip = self._get_response_text(response) 84 | 85 | return self._real_ip 86 | 87 | def get_current_ip(self): 88 | """ 89 | Get the current IP Tor is using. 90 | 91 | :returns str 92 | :raises TorIpError 93 | """ 94 | response = get( 95 | ICANHAZIP_HTTPS, 96 | proxies={"http": self.local_http_proxy, "https": self.local_http_proxy}, 97 | ) 98 | 99 | if response.ok: 100 | return self._get_response_text(response) 101 | 102 | raise TorIpError("Failed to get the current Tor IP") 103 | 104 | def get_new_ip(self): 105 | """ 106 | Try to obtain new a usable TOR IP. 107 | 108 | :returns bool 109 | :raises TorIpError 110 | """ 111 | attempts = 0 112 | 113 | while True: 114 | if attempts == self.new_ip_max_attempts: 115 | raise TorIpError("Failed to obtain a new usable Tor IP") 116 | 117 | attempts += 1 118 | 119 | try: 120 | current_ip = self.get_current_ip() 121 | except (RequestException, TorIpError): 122 | self._obtain_new_ip() 123 | continue 124 | 125 | if not self._ip_is_usable(current_ip): 126 | self._obtain_new_ip() 127 | continue 128 | 129 | self._manage_used_ips(current_ip) 130 | break 131 | 132 | return current_ip 133 | 134 | def _get_response_text(self, response): 135 | return response.text.strip() 136 | 137 | def _ip_is_safe(self, current_ip): 138 | """ 139 | Check if it's safe to (re-)use the current IP. 140 | 141 | :argument current_ip: current Tor IP 142 | :type current_ip: str 143 | 144 | :returns bool 145 | """ 146 | return current_ip not in self.used_ips 147 | 148 | def _ip_is_usable(self, current_ip): 149 | """ 150 | Check if the current Tor's IP is usable. 151 | 152 | :argument current_ip: current Tor IP 153 | :type current_ip: str 154 | 155 | :returns bool 156 | """ 157 | # Consider IP addresses only. 158 | try: 159 | ipaddress.ip_address(current_ip) 160 | except ValueError: 161 | return False 162 | 163 | # Never use real IP. 164 | if current_ip == self.real_ip: 165 | return False 166 | 167 | # Do dot allow IP reuse. 168 | if not self._ip_is_safe(current_ip): 169 | return False 170 | 171 | return True 172 | 173 | def _manage_used_ips(self, current_ip): 174 | """ 175 | Handle registering and releasing used Tor IPs. 176 | 177 | :argument current_ip: current Tor IP 178 | :type current_ip: str 179 | """ 180 | # Register current IP. 181 | self.used_ips.append(current_ip) 182 | 183 | # Release the oldest registered IP. 184 | if self.reuse_threshold: 185 | if len(self.used_ips) > self.reuse_threshold: 186 | del self.used_ips[0] 187 | 188 | def _obtain_new_ip(self): 189 | """ 190 | Change Tor's IP. 191 | """ 192 | 193 | with Controller.from_port( 194 | address=self.tor_address, port=self.tor_port 195 | ) as controller: 196 | if not controller.is_newnym_available(): 197 | sleep(controller.get_newnym_wait() + 1) 198 | 199 | controller.authenticate(password=self.tor_password) 200 | controller.signal(Signal.NEWNYM) 201 | 202 | # Wait till the IP 'settles in'. 203 | sleep(self.post_new_ip_sleep) 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/DusanMadar/TorIpChanger/actions/workflows/main.yml/badge.svg)](https://github.com/DusanMadar/TorIpChanger/actions/workflows/main.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/DusanMadar/TorIpChanger/badge.svg?branch=master)](https://coveralls.io/github/DusanMadar/TorIpChanger?branch=master) 3 | [![PyPI version](https://badge.fury.io/py/toripchanger.svg)](https://badge.fury.io/py/toripchanger) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 5 | 6 | 7 | # TorIpChanger 8 | A simple workaround for [Tor IP changing behavior](https://stem.torproject.org/faq.html#how-do-i-request-a-new-identity-from-tor): 9 | 10 | > An important thing to note is that a new circuit does not necessarily mean a new IP address. Paths are randomly selected based on heuristics like speed and stability. There are only so many large exits in the Tor network, so it's not uncommon to reuse an exit you have had previously. 11 | 12 | ## Installation 13 | ```bash 14 | pip install toripchanger 15 | ``` 16 | 17 | 18 | ## Dependencies 19 | TorIpChanger *assumes you have installed and setup Tor and Privoxy*, for example following steps mentioned in these tutorials: 20 | 21 | * [A step-by-step guide how to use Python with Tor and Privoxy](https://gist.github.com/DusanMadar/8d11026b7ce0bce6a67f7dd87b999f6b) 22 | * [Crawling anonymously with Tor in Python](http://sacharya.com/crawling-anonymously-with-tor-in-python/) 23 | * [Alternative link (Gist)](https://gist.github.com/KhepryQuixote/46cf4f3b999d7f658853) for "Crawling anonymously with Tor in Python" 24 | 25 | Or, when using Docker, simply use https://github.com/dperson/torproxy. Refer to 26 | [Dockerfile](Dockerfile) and [docker-compose.yaml](docker-compose.yaml) for more details. 27 | 28 | ```console 29 | dm@lnx:~/code/toripchanger$ docker-compose up -d 30 | Starting toripchanger_tor-proxy_1 ... done 31 | Starting toripchanger_tor-ip-changer_1 ... done 32 | dm@lnx:~/code/toripchanger$ curl http://localhost:8080/changeip/ 33 | {"error":"","newIp":"1.2.3.4"} 34 | ``` 35 | 36 | 37 | ## Usage 38 | ### Basic examples 39 | With TorIpChanger you can define how often a Tor IP can be reused: 40 | ```python 41 | from toripchanger import TorIpChanger 42 | 43 | # Tor IP reuse is prohibited. 44 | tor_ip_changer_0 = TorIpChanger(reuse_threshold=0) 45 | current_ip = tor_ip_changer_0.get_new_ip() 46 | 47 | # Current Tor IP address can be reused after one other IP was used (default setting). 48 | tor_ip_changer_1 = TorIpChanger(local_http_proxy='http://127.0.0.1:8888') 49 | current_ip = tor_ip_changer_1 .get_new_ip() 50 | 51 | # Current Tor IP address can be reused after 5 other Tor IPs were used. 52 | tor_ip_changer_5 = TorIpChanger(tor_address="localhost", reuse_threshold=5) 53 | current_ip = tor_ip_changer_5.get_new_ip() 54 | ``` 55 | 56 | ### Remote Tor control 57 | Sometimes, typically while using Docker, you may want to control a Tor instance 58 | which doesn't run on localhost. To do this, you have two options. 59 | 60 | #### Use `0.0.0.0` as control address 61 | Set `ControlPort` to `0.0.0.0:9051` in your `torrc` file and set `tor_address` when initializing TorIpChanger 62 | ```python 63 | from toripchanger import TorIpChanger 64 | 65 | # Mirroring the setup from the local docker-compose.yaml. 66 | tor_ip_changer = TorIpChanger( 67 | tor_password="tor", 68 | tor_port=9051, 69 | tor_address="tor-proxy", 70 | local_http_proxy='http://tor-proxy:8118', 71 | ) 72 | current_ip = tor_ip_changer.get_new_ip() 73 | ``` 74 | 75 | Though, Tor is not very happy about it (and rightly so) and will warn you 76 | >You have a ControlPort set to accept connections from a non-local address. This means that programs not running on your computer can reconfigure your Tor. That's pretty bad, since the controller protocol isn't encrypted! Maybe you should just listen on 127.0.0.1 and use a tool like stunnel or ssh to encrypt remote connections to your control port. 77 | 78 | Also, you have to set either `CookieAuthentication` or `HashedControlPassword` otherwise `ControlPort` will be closed 79 | >You have a ControlPort set to accept unauthenticated connections from a non-local address. This means that programs not running on your computer can reconfigure your Tor, without even having to guess a password. That's so bad that I'm closing your ControlPort for you. If you need to control your Tor remotely, try enabling authentication and using a tool like stunnel or ssh to encrypt remote access. 80 | 81 | Please note `ControlListenAddress` config is **OBSOLETE** and Tor (tested with 0.3.3.7) will ignore it and log the following message 82 | > ``` 83 | > [warn] Skipping obsolete configuration option 'ControlListenAddress' 84 | > ``` 85 | 86 | While the config itself is obsolte, its [documentation](https://people.torproject.org/~sysrqb/webwml/docs/tor-manual.html.en#ControlListenAddress) (**not the official documentation!**) concerning the risks related to exposing `ControlPort` on `0.0.0.0` is still valid 87 | > We strongly recommend that you leave this alone unless you know what you’re doing, since giving attackers access to your control listener is really dangerous. 88 | 89 | #### Use `toripchanger_server` 90 | [toripchanger_server](scripts/toripchanger_server) script starts a simple web server which allows you to change Tor' IP remotely using an HTTP get request to `/changeip/`. The response body is always 91 | 92 | ``` 93 | { 94 | "newIp": "1.2.3.4", 95 | "error": "" 96 | } 97 | ``` 98 | with an appropriate status (`error` is an empty string when all is good). 99 | 100 | Changing Tor' IP may not be instantaneous (especially when combined with a high `reuse_threshold`) and hence your client should use a reasonable timeout (e.g. at least 60s). 101 | 102 | `toripchanger_server` takes all arguments required to initialize `TorIpChanger` plus `--server-host` and `--server-port`, for more details see the usage below. 103 | 104 | ``` 105 | usage: toripchanger_server [-h] [--server-host SERVER_HOST] 106 | [--server-port SERVER_PORT] 107 | [--reuse-threshold REUSE_THRESHOLD] 108 | [--local-http-proxy LOCAL_HTTP_PROXY] 109 | [--tor-password TOR_PASSWORD] 110 | [--tor-address TOR_ADDRESS] [--tor-port TOR_PORT] 111 | [--new-ip-max-attempts NEW_IP_MAX_ATTEMPTS] 112 | 113 | optional arguments: 114 | -h, --help show this help message and exit 115 | --server-host SERVER_HOST 116 | TorIpChanger server host (default: 0.0.0.0) 117 | --server-port SERVER_PORT 118 | TorIpChanger server port (default: 8080) 119 | --reuse-threshold REUSE_THRESHOLD 120 | Number of IPs to use before reusing the current one 121 | (default: 1) 122 | --local-http-proxy LOCAL_HTTP_PROXY 123 | Local proxy IP and port (default: http://127.0.0.1:8118) 124 | --tor-password TOR_PASSWORD 125 | Tor controller password (default: "") 126 | --tor-address TOR_ADDRESS 127 | IP address or resolvable hostname of the Tor 128 | controller (default: 127.0.0.1) 129 | --tor-port TOR_PORT Port number of the Tor controller (default: 9051) 130 | --new-ip-max-attempts NEW_IP_MAX_ATTEMPTS 131 | Get new IP attempts limit (default: 10) 132 | 133 | ``` 134 | 135 | To be able to change Tor IP remotely with `toripchanger_server` 136 | 137 | 1. run `pip install toripchanger[server]` in your container 138 | 2. start `toripchanger_server` (on the same host where Tor runs) 139 | 3. expose the port `toripchanger_server` runs on to Docker host (or other containers) 140 | 4. test changing IP works, e.g. `curl http://localhost:8080/changeip/` 141 | 142 | An example [docker-compose.yaml](docker-compose.yaml) can be used for testing 143 | as instructed in section [Dependencies](#dependencies). 144 | -------------------------------------------------------------------------------- /tests/test_changer.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import unittest 3 | from unittest.mock import patch, Mock 4 | 5 | from stem import Signal 6 | 7 | from toripchanger.changer import ( 8 | ICANHAZIP_HTTPS, 9 | LOCAL_HTTP_PROXY, 10 | NEW_IP_MAX_ATTEMPTS, 11 | TorIpChanger, 12 | TorIpError, 13 | TOR_PASSWORD, 14 | TOR_ADDRESS, 15 | TOR_PORT, 16 | ) 17 | 18 | 19 | class TestTorIpChanger(unittest.TestCase): 20 | def test_init(self): 21 | """ 22 | Test that 'TorIpChanger' init sets expected (default) attributes. 23 | """ 24 | tor_ip_changer = TorIpChanger() 25 | 26 | self.assertEqual(tor_ip_changer.reuse_threshold, 1) 27 | self.assertFalse(tor_ip_changer.used_ips) 28 | 29 | def test_init_reuse_threshold(self): 30 | """ 31 | Test that 'TorIpChanger' init sets reuse_threshold when proxied. 32 | """ 33 | tor_ip_changer = TorIpChanger(5) 34 | 35 | self.assertEqual(tor_ip_changer.reuse_threshold, 5) 36 | 37 | @patch("toripchanger.changer.get") 38 | @patch("toripchanger.changer.TorIpChanger._get_response_text") 39 | def test_real_ip(self, mock_get_response_text, mock_get): 40 | """ 41 | Test that 'real_ip' is obtained without routing the request through 42 | Tor/Privoxy. 43 | """ 44 | mock_response = Mock() 45 | mock_get.return_value = mock_response 46 | 47 | tor_ip_changer = TorIpChanger() 48 | tor_ip_changer.real_ip 49 | 50 | mock_get.assert_called_once_with(ICANHAZIP_HTTPS) 51 | mock_get_response_text.assert_called_once_with(mock_response) 52 | 53 | @patch("toripchanger.changer.get") 54 | @patch("toripchanger.changer.TorIpChanger._get_response_text") 55 | def test_current_ip(self, mock_get_response_text, mock_get): 56 | """ 57 | Test that 'real_ip' is obtained routing the request through 58 | Tor/Privoxy. 59 | """ 60 | mock_get_response_text.return_value = "9.9.9.9" 61 | 62 | tor_ip_changer = TorIpChanger() 63 | current_ip = tor_ip_changer.get_current_ip() 64 | 65 | self.assertEqual(current_ip, mock_get_response_text.return_value) 66 | 67 | mock_get.assert_called_once_with( 68 | ICANHAZIP_HTTPS, 69 | proxies={"http": LOCAL_HTTP_PROXY, "https": LOCAL_HTTP_PROXY}, 70 | ) 71 | 72 | @patch("toripchanger.changer.get") 73 | def test_current_ip_exception(self, mock_get): 74 | """ 75 | Test that 'get_current_ip' raises TorIpError when an IP isn't 76 | returned. 77 | """ 78 | mock_response = Mock() 79 | mock_response.ok = False 80 | mock_get.return_value = mock_response 81 | 82 | tor_ip_changer = TorIpChanger() 83 | 84 | with self.assertRaises(TorIpError): 85 | tor_ip_changer.get_current_ip() 86 | 87 | @patch("toripchanger.changer.TorIpChanger.get_current_ip") 88 | @patch("toripchanger.changer.TorIpChanger._obtain_new_ip") 89 | def test_get_new_ip_current_ip_failed( 90 | self, mock_obtain_new_ip, mock_get_current_ip 91 | ): 92 | """ 93 | Test that 'get_new_ip' attempts to get a new IP only the limited 94 | number of times when 'get_current_ip' keeps failing. 95 | """ 96 | mock_get_current_ip.side_effect = TorIpError 97 | 98 | tor_ip_changer = TorIpChanger() 99 | 100 | with self.assertRaises(TorIpError): 101 | tor_ip_changer.get_new_ip() 102 | 103 | self.assertEqual(mock_obtain_new_ip.call_count, NEW_IP_MAX_ATTEMPTS) 104 | self.assertEqual(mock_get_current_ip.call_count, NEW_IP_MAX_ATTEMPTS) 105 | 106 | @patch("toripchanger.changer.TorIpChanger.get_current_ip") 107 | @patch("toripchanger.changer.TorIpChanger._obtain_new_ip") 108 | @patch("toripchanger.changer.TorIpChanger._ip_is_usable") 109 | def test_get_new_ip_ip_is_usable_failed( 110 | self, mock_ip_is_usable, mock_obtain_new_ip, mock_get_current_ip 111 | ): 112 | """ 113 | Test that 'get_new_ip' attempts to get a new IP only the limited 114 | number of times when '_ip_is_usable' keeps returning the same IP. 115 | """ 116 | mock_get_current_ip.return_value = "1.1.1.1" 117 | mock_ip_is_usable.return_value = False 118 | 119 | tor_ip_changer = TorIpChanger() 120 | tor_ip_changer.used_ips = ["1.1.1.1"] 121 | 122 | with self.assertRaises(TorIpError): 123 | tor_ip_changer.get_new_ip() 124 | 125 | self.assertEqual(mock_obtain_new_ip.call_count, NEW_IP_MAX_ATTEMPTS) 126 | self.assertEqual(mock_get_current_ip.call_count, NEW_IP_MAX_ATTEMPTS) 127 | self.assertEqual(mock_ip_is_usable.call_count, NEW_IP_MAX_ATTEMPTS) 128 | 129 | @patch("toripchanger.changer.TorIpChanger.get_current_ip") 130 | @patch("toripchanger.changer.TorIpChanger._obtain_new_ip") 131 | def test_get_new_ip_success(self, mock_obtain_new_ip, mock_get_current_ip): 132 | """ 133 | Test that 'get_new_ip' gets a new usable Tor IP address on the third 134 | attempt. 135 | """ 136 | mock_get_current_ip.side_effect = ["1.1.1.1", None, "2.2.2.2"] 137 | 138 | tor_ip_changer = TorIpChanger() 139 | tor_ip_changer._real_ip = "0.0.0.0" 140 | tor_ip_changer.used_ips = ["1.1.1.1"] 141 | 142 | new_ip = tor_ip_changer.get_new_ip() 143 | 144 | self.assertEqual(new_ip, "2.2.2.2") 145 | self.assertEqual([new_ip], tor_ip_changer.used_ips) 146 | self.assertEqual(mock_obtain_new_ip.call_count, 2) 147 | self.assertEqual(mock_get_current_ip.call_count, 3) 148 | 149 | def test_get_response_text(self): 150 | """ 151 | Test that '_get_response_text' accesses and strips response's text 152 | property. 153 | """ 154 | mock_response = Mock() 155 | mock_response.text = " 8.8.8.8\n" 156 | 157 | tor_ip_changer = TorIpChanger() 158 | response = tor_ip_changer._get_response_text(mock_response) 159 | 160 | self.assertEqual(response, "8.8.8.8") 161 | 162 | def test_ip_was_used(self): 163 | """ 164 | Test '_ip_was_used' recognizes an already used IP. 165 | """ 166 | tor_ip_changer = TorIpChanger() 167 | 168 | # Using '8.8.8.8' the first time. 169 | ip_is_safe = tor_ip_changer._ip_is_safe("8.8.8.8") 170 | self.assertTrue(ip_is_safe) 171 | 172 | # Using '8.8.8.8' again while it's still known to be used already. 173 | tor_ip_changer.used_ips.append("8.8.8.8") 174 | ip_is_safe = tor_ip_changer._ip_is_safe("8.8.8.8") 175 | self.assertFalse(ip_is_safe) 176 | 177 | def test_ip_is_usable_invalid_ip(self): 178 | """ 179 | Test that '_ip_is_usable' returns False for an invalid IP. 180 | """ 181 | tor_ip_changer = TorIpChanger() 182 | 183 | ip_usable = tor_ip_changer._ip_is_usable("not-an-ip") 184 | self.assertFalse(ip_usable) 185 | 186 | def test_ip_is_usable_real_ip(self): 187 | """ 188 | Test that '_ip_is_usable' returns False for the actual real IP. 189 | """ 190 | tor_ip_changer = TorIpChanger() 191 | tor_ip_changer._real_ip = "0.0.0.0" 192 | 193 | ip_usable = tor_ip_changer._ip_is_usable("0.0.0.0") 194 | self.assertFalse(ip_usable) 195 | 196 | def test_ip_is_usable_used_ip(self): 197 | """ 198 | Test that '_ip_is_usable' returns False for an already used IP. 199 | """ 200 | tor_ip_changer = TorIpChanger() 201 | tor_ip_changer._real_ip = "0.0.0.0" 202 | tor_ip_changer.used_ips = ["1.1.1.1"] 203 | 204 | ip_usable = tor_ip_changer._ip_is_usable("1.1.1.1") 205 | self.assertFalse(ip_usable) 206 | 207 | def test_ip_is_usable_valid_ip(self): 208 | """ 209 | Test that '_ip_is_usable' returns True for a valid IP. 210 | """ 211 | tor_ip_changer = TorIpChanger() 212 | tor_ip_changer._real_ip = "0.0.0.0" 213 | 214 | ip_usable = tor_ip_changer._ip_is_usable("1.1.1.1") 215 | self.assertTrue(ip_usable) 216 | 217 | def test_manage_used_ips_registers_ip(self): 218 | """ 219 | Test that '_manage_used_ips' successfully registers current IP. 220 | """ 221 | tor_ip_changer = TorIpChanger() 222 | 223 | current_ip = "1.1.1.1" 224 | tor_ip_changer._manage_used_ips(current_ip) 225 | 226 | self.assertEqual([current_ip], tor_ip_changer.used_ips) 227 | 228 | def test_manage_usable_ips_releases_used_ip(self): 229 | """ 230 | Test that '_manage_used_ips' successfully releases an used IP. 231 | """ 232 | tor_ip_changer = TorIpChanger() 233 | tor_ip_changer.used_ips = ["1.1.1.1"] 234 | 235 | current_ip = "2.2.2.2" 236 | tor_ip_changer._manage_used_ips(current_ip) 237 | 238 | self.assertEqual([current_ip], tor_ip_changer.used_ips) 239 | 240 | def test_manage_used_ips_releases_oldest_used_ip(self): 241 | """ 242 | Test that '_manage_used_ips' successfully releases oldest used IP. 243 | """ 244 | tor_ip_changer = TorIpChanger(3) 245 | tor_ip_changer._real_ip = "0.0.0.0" 246 | tor_ip_changer.used_ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] 247 | 248 | current_ip = "4.4.4.4" 249 | expected_used_ips = ["2.2.2.2", "3.3.3.3", current_ip] 250 | 251 | tor_ip_changer._manage_used_ips(current_ip) 252 | self.assertEqual(tor_ip_changer.used_ips, expected_used_ips) 253 | 254 | @patch("toripchanger.changer.sleep") 255 | @patch("toripchanger.changer.Controller.from_port") 256 | def test_obtain_new_ip(self, mock_from_port, mock_sleep): 257 | """ 258 | Test that '_obtain_new_ip' obtains new Tor IP and expected methods are 259 | called while doing so within the context manager. 260 | """ 261 | tor_ip_changer = TorIpChanger(post_new_ip_sleep=1.0) 262 | tor_ip_changer._obtain_new_ip() 263 | 264 | mock_from_port.assert_any_call(address=TOR_ADDRESS, port=TOR_PORT) 265 | 266 | mock_controler = mock_from_port.return_value.__enter__() 267 | mock_controler.signal.assert_any_call(Signal.NEWNYM) 268 | mock_controler.authenticate.assert_any_call(password=TOR_PASSWORD) 269 | 270 | mock_sleep.assert_called_once_with(1.0) 271 | 272 | def test_init_resolve_hostname(self): 273 | """ 274 | Test that 'TorIpChanger()' resolves hostnames on initialization. 275 | """ 276 | # Attempt to initialize `TorIpChanger` with an 277 | # unresolvable name fails with socket.gaierror(). 278 | with self.assertRaises(socket.gaierror): 279 | TorIpChanger(tor_address="unresolvable.address") 280 | 281 | # These 2 ways of initializing TorIpChanger are equivalent 282 | # (because `tor_address` defaults to "127.0.0.1"). 283 | changer1 = TorIpChanger(tor_address="localhost") 284 | changer2 = TorIpChanger() 285 | self.assertEqual(changer1.tor_address, changer2.tor_address) 286 | --------------------------------------------------------------------------------