From e245c4e6973b1c02c2f30cf27ba2b6b5f1f7ea9e Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sun, 18 Oct 2026 18:13:45 +0200 Subject: [PATCH] feat(country): support static and dynamic country-specific exit nodes - Add TORPROXY_COUNTRY and TORPROXY_STRICT_NODES for static startup routing - Bundle tor-geoipdb in container and configure GeoIP database paths - Add dynamic country switching and strict_nodes support to /reconnect and /connect - Expose exit_country and strict_nodes in /health, /diagnostics, and /metrics - Automate Docker Hub DOCKERHUB.md sync on release tags in publish workflow - Add Docker pulls badge to README.md and unify documentation --- .env.example | 6 + .github/workflows/publish-dockerhub.yml | 1 - DOCKERHUB.md | 23 ++- README.md | 19 +- docker/Dockerfile | 4 +- docker/api.py | 236 ++++++++++++++++++++++-- docker/start.sh | 67 +++++++ docker/torrc | 2 + docs/api.md | 42 ++++- docs/compatibility.md | 2 + docs/configuration.md | 37 +++- docs/releases.md | 4 +- pyproject.toml | 2 +- tests/conftest.py | 6 +- tests/unit/test_api.py | 168 +++++++++++++++++ tests/unit/test_config.py | 143 ++++++++++++++ tests/unit/test_contracts.py | 2 + tests/unit/test_observability.py | 39 ++++ 18 files changed, 769 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 81c02e1..2bd341e 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ TORPROXY_API_PORT=8080 # Tor SOCKS5 listener inside the container and host port mapping. TORPROXY_SOCKS_PORT=9050 + +# Optional: Specific exit country code (e.g. DE, AT, US, or comma-separated DE,AT). +# TORPROXY_COUNTRY=DE + +# Optional: Enforce strict exit node routing (1=strictly specified country, 0=allow fallback). +# TORPROXY_STRICT_NODES=1 diff --git a/.github/workflows/publish-dockerhub.yml b/.github/workflows/publish-dockerhub.yml index de01108..a138e6e 100644 --- a/.github/workflows/publish-dockerhub.yml +++ b/.github/workflows/publish-dockerhub.yml @@ -54,7 +54,6 @@ jobs: - name: Update Docker Hub Description uses: peter-evans/dockerhub-description@v4 - if: github.ref == 'refs/heads/main' with: username: hapheus password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 1ece4b3..4fd859d 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -19,6 +19,7 @@ Source repository: [https://github.com/hapheus/torproxy](https://github.com/haph - **Authenticated Tor Control**: Local Tor ControlPort is secured using ephemeral 32-byte filesystem cookie authentication. - **Non-Root Execution**: Runs under a dedicated, unprivileged system user (`torproxy`), never as root. - **Multi-Architecture**: Built natively for `linux/amd64`, `linux/arm64`, and `linux/arm/v7`. +- **Country-Specific Exit Nodes**: Route traffic through exit relays in specific countries (e.g. `TORPROXY_COUNTRY=DE` or `DE,AT`) backed by `tor-geoipdb`. - **Zero Breaking Changes**: Guaranteed backward compatibility across minor releases. --- @@ -51,6 +52,19 @@ services: - "127.0.0.1:8080:8080" ``` +### Option 3: Country-Specific Exit Proxy (e.g. Germany or Austria) + +```bash +docker run -d \ + --name torproxy \ + --restart unless-stopped \ + -e TORPROXY_COUNTRY=DE \ + -p 127.0.0.1:8118:8118 \ + -p 127.0.0.1:9050:9050 \ + -p 127.0.0.1:8080:8080 \ + hapheus/torproxy:latest +``` + --- ## Verifying Tor Connectivity @@ -69,6 +83,9 @@ curl --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip # 4. Request a new Tor identity / circuit curl -X POST http://127.0.0.1:8080/reconnect + +# 5. Dynamically switch exit country without restart +curl -X POST "http://127.0.0.1:8080/reconnect?country=DE" ``` --- @@ -84,9 +101,9 @@ The management API listens on port `8080` (HTTP): | `/health` | `GET` | Multi-subsystem health check (`tor`, `proxies`, `egress`). Returns HTTP 200 when healthy, 503 during bootstrap/errors. | | `/diagnostics` | `GET` | Uptime, reconnect counters, bootstrap details, and cache status. | | `/metrics` | `GET` | Prometheus exposition format metrics. | -| `/reconnect` | `POST` (or `GET`) | Triggers `SIGNAL NEWNYM` to establish a new Tor circuit. | +| `/reconnect` | `POST` (or `GET`) | Triggers `SIGNAL NEWNYM` to establish a new circuit. Supports `?country=DE` query or JSON body. | | `/disconnect` | `POST` (or `GET`) | Disables Tor network traffic (`SETCONF DisableNetwork=1`). | -| `/connect` | `POST` (or `GET`) | Re-enables Tor network traffic (`SETCONF DisableNetwork=0`). | +| `/connect` | `POST` (or `GET`) | Re-enables Tor network traffic (`SETCONF DisableNetwork=0`). Supports `?country=DE` query or JSON body. | > [!WARNING] > The management API is unauthenticated by default. Bind it strictly to `127.0.0.1` or place it behind an authenticating reverse proxy if exposed across networks. @@ -101,6 +118,8 @@ The management API listens on port `8080` (HTTP): | `TORPROXY_LISTEN_PORT` | `8118` | Privoxy HTTP proxy port. | | `TORPROXY_SOCKS_PORT` | `9050` | Tor SOCKS5 proxy port. | | `TORPROXY_API_PORT` | `8080` | REST API port. | +| `TORPROXY_COUNTRY` | *(unset)* | Comma-separated ISO 3166-1 alpha-2 codes (e.g. `DE`, `AT`, `US`, `DE,AT`) to restrict exit nodes. | +| `TORPROXY_STRICT_NODES` | `0` | If `1`/`true`, refuse circuits if nodes in the requested country/countries are unavailable. | --- diff --git a/README.md b/README.md index 9cb6879..8357935 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![PR Quality & Smoke Gate](https://github.com/hapheus/torproxy/actions/workflows/pr-gate.yml/badge.svg)](https://github.com/hapheus/torproxy/actions/workflows/pr-gate.yml) [![Daily Live Image Verification](https://github.com/hapheus/torproxy/actions/workflows/daily-image-verification.yml/badge.svg)](https://github.com/hapheus/torproxy/actions/workflows/daily-image-verification.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/hapheus/torproxy)](https://hub.docker.com/r/hapheus/torproxy) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) A minimal, secure, and reliable Tor HTTP and SOCKS5 proxy packaged for Docker. `torproxy` routes traffic through the Tor network with fail-closed protection, non-root execution, an authenticated Tor control interface, and a lightweight JSON REST API for circuit rotation and health monitoring. @@ -43,6 +44,18 @@ curl -v --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip # 4. Check status via the local REST API curl http://127.0.0.1:8080/status + +# 5. Optional: Route exit traffic through a specific country (e.g. DE or AT) +TORPROXY_COUNTRY=DE docker compose up -d +# or multiple countries: +TORPROXY_COUNTRY=DE,AT docker compose up -d + +# 6. Dynamically switch exit country on reconnect without container restart: +curl -X POST "http://127.0.0.1:8080/reconnect?country=DE" +# or with JSON payload: +curl -X POST http://127.0.0.1:8080/reconnect \ + -H "Content-Type: application/json" \ + -d '{"country": "AT", "strict_nodes": true}' ``` > **Security Note:** In `docker-compose.yml`, all ports bind strictly to `127.0.0.1` by default. Neither the proxy listeners nor the management API require authentication; do not expose them to untrusted networks. @@ -70,9 +83,9 @@ The lightweight API server on port `8080` manages circuits and monitors health w | `/health`, `/v1/health` | `GET` | `200`, `503` | Structured diagnostic health state across Tor, proxies, and egress. | `{"status":"healthy","tor":{"bootstrapped":true,"progress":100},"proxies":{"socks5":true},"egress":{"verified":true,"ip":"..."}}` | | `/diagnostics`, `/v1/diagnostics` | `GET` | `200` | Operational telemetry (uptime, reconnect counts, bootstrap progress). | `{"uptime_seconds":120,"reconnect_total":2,"tor":{"bootstrapped":true}}` | | `/metrics`, `/v1/metrics` | `GET` | `200` | Prometheus exposition format metrics for scrapers. | Plaintext Prometheus metrics. | -| `/reconnect` | `POST` (or `GET`) | `200` | Request a new Tor circuit (`SIGNAL NEWNYM`). Clears IP cache. | `{"action":"reconnect","status":"success"}` | +| `/reconnect` | `POST` (or `GET`) | `200`, `400` | Request new circuit (`SIGNAL NEWNYM`). Supports `?country=DE` or JSON body. Clears IP cache. | `{"action":"reconnect","status":"success","exit_country":"DE"}` | | `/disconnect` | `POST` (or `GET`) | `200` | Pause Tor network traffic (`DisableNetwork=1`). Fails closed. | `{"action":"disconnect","status":"success"}` | -| `/connect` | `POST` (or `GET`) | `200` | Resume Tor network traffic (`DisableNetwork=0`). | `{"action":"connect","status":"success"}` | +| `/connect` | `POST` (or `GET`) | `200`, `400` | Resume Tor network traffic (`DisableNetwork=0`). Supports `?country=DE` or JSON body. | `{"action":"connect","status":"success"}` | > **HTTP Methods:** `POST` is recommended for state mutations (`/reconnect`, `/disconnect`, `/connect`). Legacy `GET` calls remain fully supported for backward compatibility. > Detailed schema definitions are documented in [docs/api.md](docs/api.md). @@ -111,6 +124,8 @@ Default settings require no configuration. You can customize listening ports and | `TORPROXY_HOST_PORT` | `8118` | `1`–`65535` | Host port mapped to HTTP proxy in `docker-compose.yml`. | | `TORPROXY_API_PORT` | `8080` | `1024`–`65535` | REST API listening port (host and container). | | `TORPROXY_SOCKS_PORT` | `9050` | `1024`–`65535` | Tor SOCKS5 proxy port (host and container). | +| `TORPROXY_COUNTRY` | *(unset)* | ISO 3166-1 alpha-2 (e.g. `DE`, `AT`, `US`, `DE,AT`) | Route Tor exit circuits through specific countries using `tor-geoipdb`. | +| `TORPROXY_STRICT_NODES` | `0` | `0`, `1`, `true`, `false`, `yes`, `no` | When `1`/`true`, reject circuits if configured country exit nodes are unavailable. | All variables are strictly validated at startup. Port conflicts or out-of-range values trigger an immediate diagnostic failure. See [docs/configuration.md](docs/configuration.md) for full details. diff --git a/docker/Dockerfile b/docker/Dockerfile index 78618d3..d19874e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,13 +2,13 @@ FROM debian:bookworm-slim LABEL org.opencontainers.image.title="torproxy" \ org.opencontainers.image.description="A minimal HTTP proxy routed through Tor" \ - org.opencontainers.image.version="1.2.0" \ + org.opencontainers.image.version="1.3.0" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.source="https://github.com/hapheus/torproxy" \ org.opencontainers.image.url="https://github.com/hapheus/torproxy" RUN apt-get update \ - && apt-get install --no-install-recommends -y ca-certificates curl privoxy tini tor python3 \ + && apt-get install --no-install-recommends -y ca-certificates curl privoxy tini tor tor-geoipdb python3 \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --home-dir /var/lib/tor --shell /usr/sbin/nologin torproxy \ && install -d -m 0700 -o torproxy -g torproxy /var/lib/tor \ diff --git a/docker/api.py b/docker/api.py index c6e1182..6f72062 100644 --- a/docker/api.py +++ b/docker/api.py @@ -5,6 +5,7 @@ import socket import threading import time +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -26,6 +27,12 @@ _reconnect_count = 0 _last_reconnect_time = None +# Active country exit node tracking +_country_lock = threading.Lock() +_active_country = None +_active_strict_nodes = None +_country_state_initialized = False + def send_tor_command(command): return tor_control.send_command(command) @@ -53,6 +60,117 @@ def reset_metrics(): _last_reconnect_time = None +def get_configured_country(): + val = os.environ.get("TORPROXY_COUNTRY") or os.environ.get("TORPROXY_EXIT_COUNTRY") or "" + val = val.strip() + return val.upper() if val else None + + +def get_strict_nodes(): + val = os.environ.get("TORPROXY_STRICT_NODES") + if val is not None: + return val.strip().lower() in ("1", "true", "yes") + return bool(get_configured_country()) + + +def get_current_country(): + global _country_state_initialized, _active_country, _active_strict_nodes + with _country_lock: + if not _country_state_initialized: + _active_country = get_configured_country() + _active_strict_nodes = get_strict_nodes() + _country_state_initialized = True + return _active_country + + +def get_current_strict_nodes(): + global _country_state_initialized, _active_country, _active_strict_nodes + with _country_lock: + if not _country_state_initialized: + _active_country = get_configured_country() + _active_strict_nodes = get_strict_nodes() + _country_state_initialized = True + return _active_strict_nodes + + +def set_current_country_state(country, strict_nodes=None): + global _country_state_initialized, _active_country, _active_strict_nodes + with _country_lock: + _active_country = country + if strict_nodes is not None: + _active_strict_nodes = strict_nodes + _country_state_initialized = True + + +def reset_country_state(): + global _country_state_initialized, _active_country, _active_strict_nodes + with _country_lock: + _active_country = None + _active_strict_nodes = None + _country_state_initialized = False + + +def validate_country_input(country_input): + """Validates country input. + + Accepts: + - string: "DE", "DE,AT", "de, at", "", "none", "any", "all", "*" + - list: ["DE", "AT"] + - None (unspecified) + Returns: + (tor_exit_nodes_param, normalized_country_string, error_message) + """ + if country_input is None: + return None, None, None + + if isinstance(country_input, list): + parts = [str(item).strip() for item in country_input] + elif isinstance(country_input, str): + raw = country_input.strip() + if raw.lower() in ("", "any", "all", "none", "*"): + return "", None, None + parts = [p.strip() for p in raw.split(",") if p.strip()] + else: + return None, None, "Invalid country parameter format. Expected string or array of strings." + + if not parts: + return "", None, None + + normalized = [] + tor_nodes = [] + for p in parts: + if not re.match(r"^[a-zA-Z]{2}$", p): + return None, None, f"Invalid ISO country code: '{p}'. Must be 2-letter alpha code." + normalized.append(p.upper()) + tor_nodes.append(f"{{{p.lower()}}}") + + return ",".join(tor_nodes), ",".join(normalized), None + + +def validate_strict_nodes_input(strict_input): + """Validates strict_nodes input. + + Accepts: bool, int (0, 1), or str ("1", "0", "true", "false", "yes", "no"). + Returns: (strict_bool, error_message) + """ + if strict_input is None: + return None, None + if isinstance(strict_input, bool): + return strict_input, None + if isinstance(strict_input, int): + if strict_input in (0, 1): + return bool(strict_input), None + return None, "strict_nodes integer must be 0 or 1" + if isinstance(strict_input, str): + s = strict_input.strip().lower() + if s in ("1", "true", "yes"): + return True, None + if s in ("0", "false", "no"): + return False, None + return None, f"Invalid boolean value for strict_nodes: '{strict_input}'" + return None, "strict_nodes must be boolean, integer (0/1), or boolean string" + + def check_socks5_ready(port=None, timeout=1.5): """Verifies that Tor's SOCKS5 proxy accepts connections and completes a handshake.""" target_port = port or SOCKS_PORT @@ -157,7 +275,31 @@ def do_POST(self): self.handle_request() def handle_request(self): - path = self.path.strip("/") + parsed = urllib.parse.urlparse(self.path) + path = parsed.path.strip("/") + query_params = urllib.parse.parse_qs(parsed.query) + + req_country = query_params.get("country", [None])[0] + req_strict = query_params.get("strict_nodes", query_params.get("strict", [None]))[0] + + if self.command == "POST": + content_length_str = self.headers.get("Content-Length") + if content_length_str: + try: + content_length = int(content_length_str) + if content_length > 0: + body_bytes = self.rfile.read(content_length) + body_data = json.loads(body_bytes.decode("utf-8")) + if isinstance(body_data, dict): + if "country" in body_data: + req_country = body_data["country"] + if "strict_nodes" in body_data: + req_strict = body_data["strict_nodes"] + elif "strict" in body_data: + req_strict = body_data["strict"] + except Exception as e: + self.send_json(400, {"error": f"Malformed JSON request body: {e}"}) + return if path in ("status", ""): resp, err = send_tor_command("GETCONF DisableNetwork") @@ -212,13 +354,63 @@ def handle_request(self): "detail": err, }) - elif path == "connect": - _, err = send_tor_command("SETCONF DisableNetwork=0") - if err: - self.send_json(500, {"error": f"Failed to enable Tor network: {err}"}) + elif path in ("connect", "reconnect"): + tor_nodes, norm_country, c_err = validate_country_input(req_country) + if c_err: + self.send_json(400, {"error": c_err}) + return + + strict_bool, s_err = validate_strict_nodes_input(req_strict) + if s_err: + self.send_json(400, {"error": s_err}) + return + + if req_country is not None: + cmd = f"SETCONF ExitNodes={tor_nodes}" if tor_nodes else "SETCONF ExitNodes=" + _, err = send_tor_command(cmd) + if err: + self.send_json(500, {"error": f"Failed to configure ExitNodes in Tor: {err}"}) + return + + if strict_bool is not None: + val = 1 if strict_bool else 0 + _, err = send_tor_command(f"SETCONF StrictNodes={val}") + if err: + self.send_json(500, {"error": f"Failed to configure StrictNodes in Tor: {err}"}) + return + + if req_country is not None or strict_bool is not None: + new_country = norm_country if req_country is not None else get_current_country() + new_strict = ( + strict_bool if strict_bool is not None else get_current_strict_nodes() + ) + set_current_country_state(new_country, new_strict) + + if path == "connect": + _, err = send_tor_command("SETCONF DisableNetwork=0") + if err: + self.send_json(500, {"error": f"Failed to enable Tor network: {err}"}) + else: + clear_ip_cache() + resp_data = {"action": "connect", "status": "success"} + curr_c = get_current_country() + if curr_c: + resp_data["exit_country"] = curr_c + resp_data["strict_nodes"] = get_current_strict_nodes() + self.send_json(200, resp_data) else: - clear_ip_cache() - self.send_json(200, {"action": "connect", "status": "success"}) + _, err = send_tor_command("SIGNAL NEWNYM") + if err: + self.send_json(500, {"error": f"Failed to trigger reconnect: {err}"}) + else: + clear_ip_cache() + record_reconnect() + resp_data = {"action": "reconnect", "status": "success"} + curr_c = get_current_country() + if curr_c: + resp_data["exit_country"] = curr_c + resp_data["strict_nodes"] = get_current_strict_nodes() + self.send_json(200, resp_data) elif path == "disconnect": _, err = send_tor_command("SETCONF DisableNetwork=1") @@ -228,15 +420,6 @@ def handle_request(self): clear_ip_cache() self.send_json(200, {"action": "disconnect", "status": "success"}) - elif path == "reconnect": - _, err = send_tor_command("SIGNAL NEWNYM") - if err: - self.send_json(500, {"error": f"Failed to trigger reconnect: {err}"}) - else: - clear_ip_cache() - record_reconnect() - self.send_json(200, {"action": "reconnect", "status": "success"}) - elif path in ("health", "v1/health"): resp, err = send_tor_command("GETCONF DisableNetwork") if err: @@ -247,6 +430,9 @@ def handle_request(self): return network_enabled = "DisableNetwork=0" in resp + country = get_current_country() + strict_nodes = get_current_strict_nodes() + if not network_enabled: self.send_json(200, { "status": "disconnected", @@ -254,6 +440,8 @@ def handle_request(self): "network_enabled": False, "bootstrapped": False, "progress": 0, + "exit_country": country, + "strict_nodes": strict_nodes, }, "proxies": { "socks5": False, @@ -283,6 +471,8 @@ def handle_request(self): "bootstrapped": False, "progress": progress, "summary": summary, + "exit_country": country, + "strict_nodes": strict_nodes, }, "proxies": { "socks5": socks_ready, @@ -304,6 +494,8 @@ def handle_request(self): "bootstrapped": True, "progress": 100, "summary": "Done", + "exit_country": country, + "strict_nodes": strict_nodes, }, "proxies": { "socks5": socks_ready, @@ -349,6 +541,8 @@ def handle_request(self): if bootstrap else (b_err or "Unknown") ), + "exit_country": get_current_country(), + "strict_nodes": get_current_strict_nodes(), }, "proxies": { "socks5_ready": socks_ready, @@ -369,6 +563,8 @@ def handle_request(self): progress = bootstrap.get("progress", 0) if bootstrap else 0 bootstrapped = 1 if (bootstrap and bootstrap.get("bootstrapped")) else 0 socks_ready = 1 if check_socks5_ready() else 0 + country_str = get_current_country() or "" + strict_val = 1 if get_current_strict_nodes() else 0 with _metrics_lock: rec_count = _reconnect_count @@ -394,6 +590,14 @@ def handle_request(self): "# TYPE torproxy_socks5_ready gauge", f"torproxy_socks5_ready {socks_ready}", "", + "# HELP torproxy_strict_nodes Tor StrictNodes setting (1=strict, 0=flexible)", + "# TYPE torproxy_strict_nodes gauge", + f"torproxy_strict_nodes {strict_val}", + "", + "# HELP torproxy_exit_country_info Configured exit country code", + "# TYPE torproxy_exit_country_info gauge", + f'torproxy_exit_country_info{{country="{country_str}"}} 1', + "", ] self.send_text( 200, diff --git a/docker/start.sh b/docker/start.sh index c759ce3..e43dff1 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -35,6 +35,37 @@ validate_configuration() { echo "Configuration error: TORPROXY_LISTEN_PORT ($TORPROXY_LISTEN_PORT), TORPROXY_API_PORT ($TORPROXY_API_PORT), and TORPROXY_SOCKS_PORT ($TORPROXY_SOCKS_PORT) must all be distinct ports." >&2 exit 1 fi + + local country_val="${TORPROXY_COUNTRY:-${TORPROXY_EXIT_COUNTRY:-}}" + if [[ -n "$country_val" ]]; then + local cleaned_country="${country_val// /}" + if [[ "$cleaned_country" =~ ,, ]] || [[ "$cleaned_country" =~ ^, ]] || [[ "$cleaned_country" =~ ,$ ]]; then + echo "TORPROXY_COUNTRY must contain valid 2-letter ISO country codes (e.g. 'DE' or 'DE,AT'; found '$country_val')." >&2 + exit 1 + fi + local IFS=',' + # shellcheck disable=SC2206 + local countries=($cleaned_country) + if [[ ${#countries[@]} -eq 0 ]]; then + echo "TORPROXY_COUNTRY cannot be empty when specified." >&2 + exit 1 + fi + for cc in "${countries[@]}"; do + if [[ ! "$cc" =~ ^[a-zA-Z]{2}$ ]]; then + echo "TORPROXY_COUNTRY must contain valid 2-letter ISO country codes (e.g. 'DE' or 'DE,AT'; found '$country_val')." >&2 + exit 1 + fi + done + fi + + if [[ -n "${TORPROXY_STRICT_NODES:-}" ]]; then + local strict_lower + strict_lower=$(echo "${TORPROXY_STRICT_NODES}" | tr '[:upper:]' '[:lower:]') + if [[ ! "$strict_lower" =~ ^(0|1|true|false|yes|no)$ ]]; then + echo "TORPROXY_STRICT_NODES must be '0', '1', 'true', or 'false' (found '$TORPROXY_STRICT_NODES')." >&2 + exit 1 + fi + fi } shutdown() { @@ -56,6 +87,42 @@ sed \ -e "s|__TORPROXY_SOCKS_PORT__|$TORPROXY_SOCKS_PORT|" \ /etc/tor/torrc > /tmp/torrc +exit_nodes="" +country_input="${TORPROXY_COUNTRY:-${TORPROXY_EXIT_COUNTRY:-}}" +if [[ -n "$country_input" ]]; then + cleaned="${country_input// /}" + IFS=',' read -r -a ccs <<< "$cleaned" + formatted="" + for c in "${ccs[@]}"; do + lower_c=$(echo "$c" | tr '[:upper:]' '[:lower:]') + if [[ -z "$formatted" ]]; then + formatted="{$lower_c}" + else + formatted="${formatted},{$lower_c}" + fi + done + exit_nodes="$formatted" +elif [[ -n "${TORPROXY_EXIT_NODES:-}" ]]; then + exit_nodes="${TORPROXY_EXIT_NODES}" +fi + +if [[ -n "$exit_nodes" ]]; then + echo "ExitNodes $exit_nodes" >> /tmp/torrc + strict_val="${TORPROXY_STRICT_NODES:-1}" + if [[ "$strict_val" =~ ^(1|true|yes|TRUE|YES)$ ]]; then + echo "StrictNodes 1" >> /tmp/torrc + else + echo "StrictNodes 0" >> /tmp/torrc + fi +elif [[ -n "${TORPROXY_STRICT_NODES:-}" ]]; then + strict_val="${TORPROXY_STRICT_NODES}" + if [[ "$strict_val" =~ ^(1|true|yes|TRUE|YES)$ ]]; then + echo "StrictNodes 1" >> /tmp/torrc + else + echo "StrictNodes 0" >> /tmp/torrc + fi +fi + tor -f /tmp/torrc & tor_pid=$! diff --git a/docker/torrc b/docker/torrc index f12a242..d4a12a1 100644 --- a/docker/torrc +++ b/docker/torrc @@ -4,6 +4,8 @@ SocksPort 0.0.0.0:__TORPROXY_SOCKS_PORT__ ControlPort 127.0.0.1:9051 CookieAuthentication 1 DataDirectory /var/lib/tor +GeoIPFile /usr/share/tor/geoip +GeoIPv6File /usr/share/tor/geoip6 RunAsDaemon 0 AvoidDiskWrites 1 ClientOnly 1 diff --git a/docs/api.md b/docs/api.md index d154716..da4a1fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -76,11 +76,25 @@ Returns the current public Tor egress IP address, or `503 Service Unavailable` i ### GET | POST `/connect` Enables the Tor network connection (`SETCONF DisableNetwork=0`). +#### Parameters (Optional) +Parameters can be passed via query string (e.g. `?country=DE`) or JSON body for POST: +- `country` (`string` or `array[string]`): Comma-separated or array of 2-letter ISO 3166-1 alpha-2 country codes (e.g. `"DE"`, `"DE,AT"`, `["DE", "AT"]`). Pass `""` or `"any"` to remove country restriction. +- `strict_nodes` (`boolean` or `integer`): `1`/`true` or `0`/`false`. If true, circuits are refused if no exit nodes in the requested country are available. + #### Response (Success - 200 OK) ```json { "action": "connect", - "status": "success" + "status": "success", + "exit_country": "DE", + "strict_nodes": true +} +``` + +#### Response (Error - 400 Bad Request) +```json +{ + "error": "Invalid ISO country code: 'XYZ'. Must be 2-letter alpha code." } ``` @@ -114,13 +128,27 @@ Disables the Tor network connection (`SETCONF DisableNetwork=1`) and clears the --- ### GET | POST `/reconnect` -Requests a new Tor circuit (`SIGNAL NEWNYM`) and invalidates the cached egress IP. Note that while Tor creates a new circuit, exit IP rotation is subject to Tor circuit availability and rate limits. +Requests a new Tor circuit (`SIGNAL NEWNYM`) and invalidates the cached egress IP. + +#### Parameters (Optional) +Parameters can be passed via query string (e.g. `?country=DE`) or JSON body for POST: +- `country` (`string` or `array[string]`): Comma-separated or array of 2-letter ISO 3166-1 alpha-2 country codes (e.g. `"DE"`, `"DE,AT"`, `["DE", "AT"]`). Pass `""` or `"any"` to remove country restriction. +- `strict_nodes` (`boolean` or `integer`): `1`/`true` or `0`/`false`. #### Response (Success - 200 OK) ```json { "action": "reconnect", - "status": "success" + "status": "success", + "exit_country": "DE", + "strict_nodes": true +} +``` + +#### Response (Error - 400 Bad Request) +```json +{ + "error": "Invalid ISO country code: 'XYZ'. Must be 2-letter alpha code." } ``` @@ -144,7 +172,9 @@ Additive diagnostic endpoint providing detailed readiness and component states w "network_enabled": true, "bootstrapped": true, "progress": 100, - "summary": "Done" + "summary": "Done", + "exit_country": "DE", + "strict_nodes": true }, "proxies": { "socks5": true @@ -173,7 +203,9 @@ Returns structured runtime diagnostics, process uptime, Tor circuit rebuild stat "network_enabled": true, "bootstrapped": true, "progress": 100, - "summary": "Done" + "summary": "Done", + "exit_country": "DE", + "strict_nodes": true }, "proxies": { "socks5_ready": true, diff --git a/docs/compatibility.md b/docs/compatibility.md index 9ae6d5c..8021576 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -41,6 +41,8 @@ This document defines the strict, non-negotiable backward compatibility contract | `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. | | `TORPROXY_HOST_PORT` | `8118` | Integer `1`–`65535` | Compose host port mapping for the HTTP proxy. | +| `TORPROXY_COUNTRY` | *(unset)* | Comma-separated ISO codes | Optional: Restricts Tor exit nodes by country. Default: standard unrestricted Tor routing. | +| `TORPROXY_STRICT_NODES` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Refuse circuits if country exit nodes are unavailable. Default: `0`. | --- diff --git a/docs/configuration.md b/docs/configuration.md index 611b79d..d66b79a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,8 @@ All settings have sensible defaults that allow running `torproxy` with zero conf | `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. | | `TORPROXY_HOST_PORT` | `8118` | Integer (1–65535) | Host port mapped to `TORPROXY_LISTEN_PORT` in the default `docker-compose.yml`. | +| `TORPROXY_COUNTRY` | *(unset)* | Comma-separated ISO codes | 2-letter ISO 3166-1 alpha-2 codes (e.g. `DE`, `AT`, `US`, `DE,AT`) to route exit traffic. | +| `TORPROXY_STRICT_NODES` | `0` | Boolean (`0`, `1`, `true`, `false`) | When `1`/`true`, strictly reject circuits if no exit relays in selected countries exist. | --- @@ -26,12 +28,15 @@ TORPROXY_LISTEN_ADDRESS=0.0.0.0 TORPROXY_LISTEN_PORT=8118 TORPROXY_API_PORT=8080 TORPROXY_SOCKS_PORT=9050 +TORPROXY_COUNTRY=DE +TORPROXY_STRICT_NODES=0 ``` ### Docker Run CLI Flags Pass environment variables directly using the `-e` flag: ```sh docker run -d --name torproxy --restart unless-stopped \ + -e TORPROXY_COUNTRY=DE \ -e TORPROXY_LISTEN_PORT=8118 \ -e TORPROXY_API_PORT=8080 \ -e TORPROXY_SOCKS_PORT=9050 \ @@ -47,10 +52,10 @@ 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), and no port conflicts exist between Privoxy, API, and SOCKS5. +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. 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 writes `/tmp/torrc`. + - Replaces `__TORPROXY_SOCKS_PORT__` in `/etc/tor/torrc` and appends `ExitNodes` and `StrictNodes` directives if configured, writing `/tmp/torrc`. 3. **Daemon Launch:** - Launches `tor -f /tmp/torrc` in the background. - Launches `privoxy --no-daemon /tmp/privoxy.config` in the background. @@ -64,3 +69,31 @@ When the container launches, `/usr/local/bin/start.sh` executes the following se - **Host Loopback Only:** By default, Docker Compose exposes ports `8118`, `8080`, and `9050` bound exclusively to `127.0.0.1`. Never expose these ports to `0.0.0.0` or public internet interfaces without an authenticating reverse proxy. - **Docker Network Isolation:** Containers within the same Docker network can communicate with `torproxy` using the service hostname `torproxy` (e.g. `http://torproxy:8118`). Shipped examples do not publish the management API to host ports. - **Tor Control Port (`9051`):** Bound strictly to `127.0.0.1:9051` inside the container. It is authenticated via `CookieAuthentication 1` (`/var/lib/tor/control_auth_cookie`) and is never exposed outside the container boundary. + +--- + +## 5. Country-Specific Exit Nodes (GeoIP Routing) + +`torproxy` bundles Debian's `tor-geoipdb` package (`/usr/share/tor/geoip` and `/usr/share/tor/geoip6`) allowing exit node country routing. + +### Static Startup Configuration +Specify `TORPROXY_COUNTRY` with comma-separated ISO 3166-1 alpha-2 codes: +- `TORPROXY_COUNTRY=DE` (Germany only) +- `TORPROXY_COUNTRY=AT,CH` (Austria or Switzerland) +- `TORPROXY_STRICT_NODES=1` (Strict mode: refuse circuits if no nodes in requested country exist) + +### Dynamic Runtime Configuration via REST API +The exit country can be changed on the fly without restarting the container: +```sh +# Switch exit country to Germany on circuit rotation +curl -X POST "http://127.0.0.1:8080/reconnect?country=DE" + +# Switch with strict nodes policy via JSON payload +curl -X POST http://127.0.0.1:8080/reconnect \ + -H "Content-Type: application/json" \ + -d '{"country": "AT", "strict_nodes": true}' + +# Clear country restriction (return to default random routing) +curl -X POST "http://127.0.0.1:8080/reconnect?country=any" +``` +Dynamic changes are applied via Tor Control Protocol (`SETCONF ExitNodes=...`) and immediately take effect for new circuits. Active configuration is surfaced in `/health`, `/diagnostics`, and `/metrics`. diff --git a/docs/releases.md b/docs/releases.md index f58ad42..ca7fd25 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -26,8 +26,8 @@ Images are published to Docker Hub at [`hapheus/torproxy`](https://hub.docker.co | Tag | Mutability | Description | | --- | ---------- | ----------- | | `latest` | Mutable | Tracks the latest stable commit on the default branch (`main`). | -| `vX.Y.Z` (e.g. `v1.2.0`) | **Immutable** | Official, cryptographically tagged release pointing to an exact commit. Recommended for production deployments. | -| `vX.Y` (e.g. `v1.2`) | Mutable | Tracks the latest patch release within a minor release stream. | +| `vX.Y.Z` (e.g. `v1.3.0`) | **Immutable** | Official, cryptographically tagged release pointing to an exact commit. Recommended for production deployments. | +| `vX.Y` (e.g. `v1.3`) | Mutable | Tracks the latest patch release within a minor release stream. | ### Supported CPU Architectures Every official image build targets the following architectures using Docker Buildx: diff --git a/pyproject.toml b/pyproject.toml index c9ed4d6..f57e20e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "torproxy" -version = "1.2.0" +version = "1.3.0" description = "A minimal HTTP and SOCKS5 proxy routed through the Tor network with local REST control API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/conftest.py b/tests/conftest.py index 211fc78..148c9d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,10 +14,14 @@ @pytest.fixture(autouse=True) -def reset_ip_cache(): +def reset_api_state(): api.clear_ip_cache() + api.reset_metrics() + api.reset_country_state() yield api.clear_ip_cache() + api.reset_metrics() + api.reset_country_state() @pytest.fixture(scope="module") diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 3dcb221..0cd977b 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -312,6 +312,174 @@ def test_reconnect_endpoint(self, api_server): assert api._cached_ip is None mock_tor.assert_called_with("SIGNAL NEWNYM") + def test_reconnect_with_country_query_param(self, api_server): + api.reset_country_state() + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/reconnect?country=DE", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "reconnect" + assert data["status"] == "success" + assert data["exit_country"] == "DE" + mock_tor.assert_any_call("SETCONF ExitNodes={de}") + mock_tor.assert_any_call("SIGNAL NEWNYM") + assert api.get_current_country() == "DE" + + def test_connect_with_country_and_strict_json_body(self, api_server): + api.reset_country_state() + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + payload = json.dumps({"country": "AT,CH", "strict_nodes": True}).encode("utf-8") + req = urllib.request.Request( + f"{api_server}/connect", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "connect" + assert data["status"] == "success" + assert data["exit_country"] == "AT,CH" + assert data["strict_nodes"] is True + mock_tor.assert_any_call("SETCONF ExitNodes={at},{ch}") + mock_tor.assert_any_call("SETCONF StrictNodes=1") + mock_tor.assert_any_call("SETCONF DisableNetwork=0") + assert api.get_current_country() == "AT,CH" + assert api.get_current_strict_nodes() is True + + def test_reconnect_clear_country(self, api_server): + api.set_current_country_state("DE", True) + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/reconnect?country=any", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "reconnect" + assert data["status"] == "success" + assert data.get("exit_country") is None + mock_tor.assert_any_call("SETCONF ExitNodes=") + mock_tor.assert_any_call("SIGNAL NEWNYM") + assert api.get_current_country() is None + + def test_reconnect_invalid_country_returns_400(self, api_server): + req = urllib.request.Request(f"{api_server}/reconnect?country=INVALID", method="POST") + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 400 + data = json.loads(exc_info.value.read().decode("utf-8")) + assert "Invalid ISO country code" in data["error"] + + def test_reconnect_invalid_json_body_returns_400(self, api_server): + req = urllib.request.Request( + f"{api_server}/reconnect", + data=b"invalid-json{", + headers={"Content-Type": "application/json"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 400 + data = json.loads(exc_info.value.read().decode("utf-8")) + assert "Malformed JSON request body" in data["error"] + + def test_reconnect_tor_error_setting_exit_nodes(self, api_server): + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = (None, "Tor syntax error in ExitNodes") + req = urllib.request.Request(f"{api_server}/reconnect?country=DE", method="POST") + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 500 + data = json.loads(exc_info.value.read().decode("utf-8")) + assert "Failed to configure ExitNodes in Tor" in data["error"] + + def test_reconnect_tor_error_setting_strict_nodes(self, api_server): + with patch("api.send_tor_command") as mock_tor: + mock_tor.side_effect = [ + ("250 OK", None), # SETCONF ExitNodes + (None, "Tor error setting strict nodes"), # SETCONF StrictNodes + ] + req = urllib.request.Request( + f"{api_server}/reconnect?country=DE&strict_nodes=1", method="POST" + ) + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 500 + data = json.loads(exc_info.value.read().decode("utf-8")) + assert "Failed to configure StrictNodes in Tor" in data["error"] + + def test_reconnect_invalid_strict_nodes_returns_400(self, api_server): + req = urllib.request.Request(f"{api_server}/reconnect?strict_nodes=maybe", method="POST") + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(req) + assert exc_info.value.code == 400 + data = json.loads(exc_info.value.read().decode("utf-8")) + assert "Invalid boolean value for strict_nodes" in data["error"] + + def test_legacy_get_reconnect_with_country(self, api_server): + api.reset_country_state() + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/reconnect?country=IT", method="GET") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "reconnect" + assert data["exit_country"] == "IT" + mock_tor.assert_any_call("SETCONF ExitNodes={it}") + assert api.get_current_country() == "IT" + + def test_reconnect_dynamic_switch_reflected_in_diagnostics(self, api_server): + api.reset_country_state() + with ( + patch("api.send_tor_command") as mock_tor, + patch("api.get_tor_bootstrap_status") as mock_boot, + patch("api.check_socks5_ready") as mock_socks, + patch("api.get_current_ip") as mock_ip, + ): + mock_tor.return_value = ("250 OK", None) + mock_boot.return_value = ({"bootstrapped": True, "progress": 100}, None) + mock_socks.return_value = True + mock_ip.return_value = ("185.220.101.5", None) + + # Reconnect with country=ES + req = urllib.request.Request(f"{api_server}/reconnect?country=ES", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + + # Query diagnostics + mock_tor.return_value = ("250 DisableNetwork=0", None) + req_d = urllib.request.Request(f"{api_server}/diagnostics", method="GET") + with urllib.request.urlopen(req_d) as resp_d: + data_d = json.loads(resp_d.read().decode("utf-8")) + assert data_d["tor"]["exit_country"] == "ES" + + def test_validation_helpers_direct(self): + # Country validation + assert api.validate_country_input(None) == (None, None, None) + assert api.validate_country_input("") == ("", None, None) + assert api.validate_country_input("any") == ("", None, None) + assert api.validate_country_input("DE") == ("{de}", "DE", None) + assert api.validate_country_input(["DE", "AT"]) == ("{de},{at}", "DE,AT", None) + assert api.validate_country_input(123)[2] is not None + assert api.validate_country_input(["DE", "INVALID"])[2] is not None + + # Strict nodes validation + assert api.validate_strict_nodes_input(None) == (None, None) + assert api.validate_strict_nodes_input(True) == (True, None) + assert api.validate_strict_nodes_input(False) == (False, None) + assert api.validate_strict_nodes_input(1) == (True, None) + assert api.validate_strict_nodes_input(0) == (False, None) + assert api.validate_strict_nodes_input("true") == (True, None) + assert api.validate_strict_nodes_input("no") == (False, None) + assert api.validate_strict_nodes_input(2)[1] is not None + assert api.validate_strict_nodes_input("invalid")[1] is not None + assert api.validate_strict_nodes_input({})[1] is not None + def test_not_found_endpoint(self, api_server): with pytest.raises(urllib.error.HTTPError) as exc_info: urllib.request.urlopen(f"{api_server}/invalid-path") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e7a9a92..c6165f9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -116,3 +116,146 @@ def test_conflicting_ports_rejected(self): assert res.returncode == 1 assert "Configuration error" in res.stderr assert "must all be distinct" in res.stderr + + @pytest.mark.parametrize("valid_country", ["DE", "at", "de,at", "US, GB, FR", "ch", "NL,SE,no"]) + def test_valid_country_codes(self, valid_country): + env = { + "TORPROXY_LISTEN_ADDRESS": "0.0.0.0", + "TORPROXY_LISTEN_PORT": "8118", + "TORPROXY_API_PORT": "8080", + "TORPROXY_SOCKS_PORT": "9050", + "TORPROXY_COUNTRY": valid_country, + } + res = self._run_validate(env) + assert res.returncode == 0, ( + f"Valid country '{valid_country}' unexpectedly failed: {res.stderr}" + ) + + @pytest.mark.parametrize( + "invalid_country", ["GER", "12", "D", "DE,,AT", "DE;AT", "!#", "DE 12"] + ) + def test_invalid_country_codes(self, invalid_country): + env = { + "TORPROXY_LISTEN_ADDRESS": "0.0.0.0", + "TORPROXY_LISTEN_PORT": "8118", + "TORPROXY_API_PORT": "8080", + "TORPROXY_SOCKS_PORT": "9050", + "TORPROXY_COUNTRY": invalid_country, + } + res = self._run_validate(env) + assert res.returncode == 1 + assert "TORPROXY_COUNTRY" in res.stderr + + @pytest.mark.parametrize( + "valid_strict", ["0", "1", "true", "false", "yes", "no", "TRUE", "False"] + ) + def test_valid_strict_nodes(self, valid_strict): + env = { + "TORPROXY_LISTEN_ADDRESS": "0.0.0.0", + "TORPROXY_LISTEN_PORT": "8118", + "TORPROXY_API_PORT": "8080", + "TORPROXY_SOCKS_PORT": "9050", + "TORPROXY_COUNTRY": "DE", + "TORPROXY_STRICT_NODES": valid_strict, + } + res = self._run_validate(env) + assert res.returncode == 0, f"Valid strict_nodes '{valid_strict}' failed: {res.stderr}" + + @pytest.mark.parametrize("invalid_strict", ["2", "-1", "maybe", "strict", "none"]) + def test_invalid_strict_nodes(self, invalid_strict): + env = { + "TORPROXY_LISTEN_ADDRESS": "0.0.0.0", + "TORPROXY_LISTEN_PORT": "8118", + "TORPROXY_API_PORT": "8080", + "TORPROXY_SOCKS_PORT": "9050", + "TORPROXY_STRICT_NODES": invalid_strict, + } + res = self._run_validate(env) + assert res.returncode == 1 + assert "TORPROXY_STRICT_NODES" in res.stderr + + +@pytest.mark.unit +class TestTorrcGeneration: + START_SCRIPT = os.path.join(DOCKER_DIR, "start.sh") + + def _render_torrc(self, env_overrides, tmp_path): + env = os.environ.copy() + env.update(env_overrides) + env["TORPROXY_SOCKS_PORT"] = "9050" + torrc_template = os.path.join(DOCKER_DIR, "torrc") + rendered_torrc = str(tmp_path / "torrc") + + # Execute the torrc generation block with /tmp/torrc redirected to tmp_path + script = f""" + sed -e "s|__TORPROXY_SOCKS_PORT__|$TORPROXY_SOCKS_PORT|" \\ + "{torrc_template}" > "{rendered_torrc}" + exit_nodes="" + country_input="${{TORPROXY_COUNTRY:-${{TORPROXY_EXIT_COUNTRY:-}}}}" + if [[ -n "$country_input" ]]; then + cleaned="${{country_input// /}}" + IFS=',' read -r -a ccs <<< "$cleaned" + formatted="" + for c in "${{ccs[@]}}"; do + lower_c=$(echo "$c" | tr '[:upper:]' '[:lower:]') + if [[ -z "$formatted" ]]; then + formatted="{{$lower_c}}" + else + formatted="${{formatted}},{{$lower_c}}" + fi + done + exit_nodes="$formatted" + elif [[ -n "${{TORPROXY_EXIT_NODES:-}}" ]]; then + exit_nodes="${{TORPROXY_EXIT_NODES}}" + fi + + if [[ -n "$exit_nodes" ]]; then + echo "ExitNodes $exit_nodes" >> "{rendered_torrc}" + strict_val="${{TORPROXY_STRICT_NODES:-1}}" + if [[ "$strict_val" =~ ^(1|true|yes|TRUE|YES)$ ]]; then + echo "StrictNodes 1" >> "{rendered_torrc}" + else + echo "StrictNodes 0" >> "{rendered_torrc}" + fi + elif [[ -n "${{TORPROXY_STRICT_NODES:-}}" ]]; then + strict_val="${{TORPROXY_STRICT_NODES}}" + if [[ "$strict_val" =~ ^(1|true|yes|TRUE|YES)$ ]]; then + echo "StrictNodes 1" >> "{rendered_torrc}" + else + echo "StrictNodes 0" >> "{rendered_torrc}" + fi + fi + """ + res = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + assert res.returncode == 0, f"Script failed: {res.stderr}" + with open(rendered_torrc, "r", encoding="utf-8") as f: + return f.read() + + def test_default_renders_no_exit_nodes(self, tmp_path): + content = self._render_torrc({}, tmp_path) + assert "ExitNodes" not in content + assert "StrictNodes" not in content + + def test_single_country_code(self, tmp_path): + content = self._render_torrc({"TORPROXY_COUNTRY": "DE"}, tmp_path) + assert "ExitNodes {de}" in content + assert "StrictNodes 1" in content + + def test_multi_country_code_case_insensitive(self, tmp_path): + content = self._render_torrc({"TORPROXY_COUNTRY": "de, AT, us"}, tmp_path) + assert "ExitNodes {de},{at},{us}" in content + assert "StrictNodes 1" in content + + def test_country_with_strict_nodes_disabled(self, tmp_path): + content = self._render_torrc( + {"TORPROXY_COUNTRY": "AT", "TORPROXY_STRICT_NODES": "0"}, tmp_path + ) + assert "ExitNodes {at}" in content + assert "StrictNodes 0" in content + + def test_direct_exit_nodes_override(self, tmp_path): + content = self._render_torrc({"TORPROXY_EXIT_NODES": "{ch},{nl}"}, tmp_path) + assert "ExitNodes {ch},{nl}" in content + assert "StrictNodes 1" in content + + diff --git a/tests/unit/test_contracts.py b/tests/unit/test_contracts.py index 7d4e1ad..18adb3f 100644 --- a/tests/unit/test_contracts.py +++ b/tests/unit/test_contracts.py @@ -275,6 +275,8 @@ def test_env_example_matches_contract(self): assert "TORPROXY_LISTEN_PORT=8118" in env_example assert "TORPROXY_API_PORT=8080" in env_example assert "TORPROXY_SOCKS_PORT=9050" in env_example + assert "TORPROXY_COUNTRY" in env_example + assert "TORPROXY_STRICT_NODES" in env_example def test_docker_compose_loopback_bindings(self): compose_path = os.path.join(REPO_ROOT, "docker-compose.yml") diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py index 3252b51..687164c 100644 --- a/tests/unit/test_observability.py +++ b/tests/unit/test_observability.py @@ -9,8 +9,10 @@ @pytest.fixture(autouse=True) def reset_metrics(): api.reset_metrics() + api.reset_country_state() yield api.reset_metrics() + api.reset_country_state() @pytest.mark.unit @@ -47,6 +49,8 @@ def test_diagnostics_endpoint_contract(self, api_server): assert data["tor"]["network_enabled"] is True assert data["tor"]["bootstrapped"] is True assert data["tor"]["progress"] == 100 + assert "exit_country" in data["tor"] + assert "strict_nodes" in data["tor"] assert "proxies" in data assert data["proxies"]["socks5_ready"] is True @@ -140,6 +144,41 @@ def test_metrics_prometheus_exposition(self, api_server): assert "torproxy_tor_bootstrapped 1" in body assert "torproxy_tor_bootstrap_progress 100" in body assert "torproxy_socks5_ready 1" in body + assert "torproxy_strict_nodes 0" in body + assert 'torproxy_exit_country_info{country=""} 1' in body + + def test_country_configuration_observability(self, api_server, monkeypatch): + monkeypatch.setenv("TORPROXY_COUNTRY", "DE") + monkeypatch.setenv("TORPROXY_STRICT_NODES", "1") + + with ( + patch("api.send_tor_command") as mock_tor, + patch("api.get_tor_bootstrap_status") as mock_boot, + patch("api.check_socks5_ready") as mock_socks, + patch("api.get_current_ip") as mock_ip, + ): + mock_tor.return_value = ("250 DisableNetwork=0", None) + mock_boot.return_value = ( + {"bootstrapped": True, "progress": 100, "summary": "Done"}, + None, + ) + mock_socks.return_value = True + mock_ip.return_value = ("185.220.101.5", None) + + # Test diagnostics + req = urllib.request.Request(f"{api_server}/diagnostics", method="GET") + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read().decode("utf-8")) + assert data["tor"]["exit_country"] == "DE" + assert data["tor"]["strict_nodes"] is True + + # Test metrics + req_m = urllib.request.Request(f"{api_server}/metrics", method="GET") + with urllib.request.urlopen(req_m) as resp_m: + body = resp_m.read().decode("utf-8") + assert 'torproxy_exit_country_info{country="DE"} 1' in body + assert "torproxy_strict_nodes 1" in body + @pytest.mark.unit