Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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. |
Expand Down Expand Up @@ -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`.
Expand Down
14 changes: 8 additions & 6 deletions src/torproxy/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions src/torproxy/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/test_ipv6.py
Original file line number Diff line number Diff line change
@@ -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()
Loading