diff --git a/docs/compatibility.md b/docs/compatibility.md index 138aa92..e2a40e0 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -36,7 +36,7 @@ This document defines the strict, non-negotiable backward compatibility contract | Variable | Default Value | Format / Validation | Guaranteed Behavior | |---|---|---|---| -| `TORPROXY_LISTEN_ADDRESS` | `0.0.0.0` | Valid IPv4 address | Bind address for Privoxy inside the container. | +| `TORPROXY_LISTEN_ADDRESS` | `0.0.0.0` | Valid IP address (IPv4 or IPv6) | Bind address for Privoxy inside the container. | | `TORPROXY_LISTEN_PORT` | `8118` | Integer `1`–`65535` | Listening port for Privoxy inside the container. | | `TORPROXY_API_PORT` | `8080` | Integer `1`–`65535` | Listening port for the Python REST API server. | | `TORPROXY_SOCKS_PORT` | `9050` | Integer `1`–`65535` | Listening port for Tor SOCKS5 inside the container. | diff --git a/docs/configuration.md b/docs/configuration.md index 297df0c..7e79d97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,7 +8,7 @@ All settings have sensible defaults that allow running `torproxy` with zero conf | Variable | Default | Type | Description | |---|---|---|---| -| `TORPROXY_LISTEN_ADDRESS` | `0.0.0.0` | IPv4 address | Address inside the container that Privoxy binds to. Set to `0.0.0.0` to permit container-to-container connections. | +| `TORPROXY_LISTEN_ADDRESS` | `0.0.0.0` | IP address (IPv4 or IPv6, e.g. `::1`) | Address inside the container that Privoxy binds to. Set to `0.0.0.0` to permit container-to-container connections. | | `TORPROXY_LISTEN_PORT` | `8118` | Integer (1–65535) | TCP port inside the container where the Privoxy HTTP proxy listens. | | `TORPROXY_API_PORT` | `8080` | Integer (1–65535) | TCP port inside the container where the Python REST API server listens. | | `TORPROXY_SOCKS_PORT` | `9050` | Integer (1–65535) | TCP port inside the container where Tor's SOCKS5 proxy listens. | @@ -17,7 +17,7 @@ All settings have sensible defaults that allow running `torproxy` with zero conf | `TORPROXY_STRICT_NODES` | `0` | Boolean (`0`, `1`, `true`, `false`) | When `1`/`true`, strictly reject circuits if no exit relays in selected countries exist. | | `TORPROXY_API_TOKEN` | *(unset)* | String | Optional: Bearer token / API key to secure REST API port `8080`. Recommended for production or shared networks. | | `TORPROXY_API_ALLOW_ANONYMOUS_HEALTH` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Permits unauthenticated access to `/health` when `TORPROXY_API_TOKEN` is configured. | -| `TORPROXY_API_LISTEN_ADDRESS` | `0.0.0.0` | IPv4 address | Address inside the container where the REST API server binds. | +| `TORPROXY_API_LISTEN_ADDRESS` | `0.0.0.0` | IP address (IPv4 or IPv6, e.g. `::1`) | Address inside the container where the REST API server binds. The server binds an `AF_INET6` socket for IPv6 values; keep loopback binding (`::1`) in production unless a reverse proxy / token protects the API. | | `TORPROXY_GATEWAY_MODE` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Enables transparent Tor Gateway mode (`TransPort`, `DNSPort`, netfilter kill switch). Requires `cap_add: [NET_ADMIN]`. | | `TORPROXY_TRANS_PORT` | `9040` | Integer (1–65535) | TCP port inside the container where Tor's transparent proxy listener (`TransPort`) binds when gateway mode is active. | | `TORPROXY_DNS_PORT` | `9053` | Integer (1–65535) | UDP/TCP port inside the container where Tor's internal DNS resolver (`DNSPort`) binds when gateway mode is active. | @@ -66,7 +66,7 @@ docker run -d --name torproxy --restart unless-stopped \ When the container launches, `/usr/local/bin/start.sh` executes the following sequence: -1. **Validation:** Checks that `TORPROXY_LISTEN_ADDRESS` has 4 valid octets (0–255), all ports are integers (1–65535), no port conflicts exist between Privoxy, API, and SOCKS5, and `TORPROXY_COUNTRY` contains valid 2-letter ISO codes. +1. **Validation:** Checks that `TORPROXY_LISTEN_ADDRESS` and `TORPROXY_API_LISTEN_ADDRESS` are valid IPv4 or IPv6 addresses, all ports are integers (1–65535), no port conflicts exist between Privoxy, API, and SOCKS5, and `TORPROXY_COUNTRY` contains valid 2-letter ISO codes. 2. **Template Substitution:** - Replaces `__TORPROXY_LISTEN_ADDRESS__`, `__TORPROXY_LISTEN_PORT__`, and `__TORPROXY_SOCKS_PORT__` in `/etc/privoxy/config.template` and writes `/tmp/privoxy.config`. - Replaces `__TORPROXY_SOCKS_PORT__` in `/etc/tor/torrc` and appends `ExitNodes` and `StrictNodes` directives if configured, writing `/tmp/torrc`. diff --git a/src/torproxy/configuration.py b/src/torproxy/configuration.py index 2c94419..a47b65e 100644 --- a/src/torproxy/configuration.py +++ b/src/torproxy/configuration.py @@ -77,14 +77,16 @@ class Settings: geoip_max_bytes: int = 10 * 1024 * 1024 def __post_init__(self) -> None: + # ISSUE-18b (#107): IPv4 and IPv6 listen addresses are + # both valid; defaults are unchanged (0.0.0.0). try: - ipaddress.IPv4Address(self.listen_address) - except ipaddress.AddressValueError: - raise ValueError("TORPROXY_LISTEN_ADDRESS must be a valid IPv4 address") from None + ipaddress.ip_address(self.listen_address) + except ValueError: + raise ValueError("TORPROXY_LISTEN_ADDRESS must be a valid IP address") from None try: - ipaddress.IPv4Address(self.api_listen_address) - except ipaddress.AddressValueError: - raise ValueError("TORPROXY_API_LISTEN_ADDRESS must be a valid IPv4 address") from None + ipaddress.ip_address(self.api_listen_address) + except ValueError: + raise ValueError("TORPROXY_API_LISTEN_ADDRESS must be a valid IP address") from None ports_to_check = [ ("TORPROXY_LISTEN_PORT", self.proxy_port), ("TORPROXY_API_PORT", self.api_port), diff --git a/src/torproxy/web/server.py b/src/torproxy/web/server.py index b73224c..6f04ae6 100644 --- a/src/torproxy/web/server.py +++ b/src/torproxy/web/server.py @@ -233,6 +233,11 @@ def __init__(self, address: tuple[str, int], router: Router, settings: Settings) self.router = router self.settings = settings self.slots = threading.BoundedSemaphore(settings.max_workers) + # ISSUE-18b (#107): bind AF_INET6 for IPv6 listen + # addresses (::1/::), AF_INET otherwise. Defaults are + # IPv4, so existing deployments are unaffected. + if ":" in address[0]: + self.address_family = socket.AF_INET6 super().__init__(address, APIHandler) def process_request( diff --git a/tests/unit/test_ipv6.py b/tests/unit/test_ipv6.py new file mode 100644 index 0000000..0967453 --- /dev/null +++ b/tests/unit/test_ipv6.py @@ -0,0 +1,56 @@ +"""ISSUE-18b (#107): IPv6 listen addresses for the API. + +Hermetic tests: settings validation plus a real loopback bind on +::1 (skipped gracefully on hosts without IPv6). +""" + +import socket + +import pytest + +from torproxy.bootstrap import create_server +from torproxy.configuration import Settings + + +@pytest.mark.unit +class TestIPv6Settings: + def test_ipv6_loopback_api_address_accepted(self): + settings = Settings(api_listen_address="::1") + assert settings.api_listen_address == "::1" + + def test_ipv6_any_api_address_accepted(self): + settings = Settings(api_listen_address="::") + assert settings.api_listen_address == "::" + + def test_ipv4_default_unchanged(self): + assert Settings().api_listen_address == "0.0.0.0" + + def test_invalid_address_still_rejected(self): + with pytest.raises(ValueError, match="TORPROXY_API_LISTEN_ADDRESS"): + Settings(api_listen_address="not-an-address") + + def test_listen_address_ipv6_accepted(self): + # Privoxy listen address validation shares the same rule. + settings = Settings(listen_address="::1") + assert settings.listen_address == "::1" + + +@pytest.mark.unit +class TestIPv6ServerBind: + def test_server_binds_ipv6_loopback(self): + try: + probe = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + probe.bind(("::1", 0)) + probe.close() + except OSError: + pytest.skip("IPv6 loopback unavailable on this host") + + settings = Settings(api_listen_address="::1") + server = create_server(settings, ("::1", 0)) + try: + assert server.address_family == socket.AF_INET6 + host, port = server.server_address[0], server.server_address[1] + assert host == "::1" + assert port > 0 + finally: + server.server_close()