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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ TORPROXY_SOCKS_PORT=9050
# TORPROXY_GEOIP_DIR=/var/lib/tor/geoip
# TORPROXY_GEOIP_URL=https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip
# TORPROXY_GEOIP6_URL=https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6
# TORPROXY_GEOIP_MAX_BYTES=10485760
# TORPROXY_GEOIP_MAX_BYTES=16777216
# TORPROXY_GEOIP6_MAX_BYTES=33554432

# Optional: Bearer token / API key to secure the REST management API (8080).
# Recommended if the management port or container network is accessible to other services.
Expand Down
510 changes: 510 additions & 0 deletions .github/actions/live-verification/action.yml

Large diffs are not rendered by default.

450 changes: 16 additions & 434 deletions .github/workflows/daily-image-verification.yml

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,24 @@ jobs:
run: |
docker rm -f torproxy-smoke torproxy-hardened-test torproxy-prev torproxy-upgrade torproxy-gateway-smoke || true
docker compose down -v || true

live-image-verification:
name: "Live Image Verification (Pre-Publish Gate)"
needs: [lint-and-validate, unit-tests, docker-smoke]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Composite action (not a reusable workflow): relative reusable
# paths fail on pull_request events (startup_failure, 0 jobs)
# because GitHub resolves them against the merge commit.
- name: Run Live Image Verification
uses: ./.github/actions/live-verification
with:
image_ref: "torproxy:smoke-test"
build_local: "true"
run_alerts: "false"
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
22 changes: 22 additions & 0 deletions .github/workflows/publish-dockerhub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,29 @@ permissions:
contents: read

jobs:
# ISSUE-117: never publish an image that has not passed the live
# Tor verification (bootstrap, egress, gateway, GeoIP) against a
# build from the same commit. Previously the image was published
# first and only checked afterwards by the daily canary.
verify-live:
name: "Live Image Verification (Pre-Publish Gate)"
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run Live Image Verification
uses: ./.github/actions/live-verification
with:
image_ref: "torproxy:smoke-test"
build_local: "true"
run_alerts: "false"
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}

publish:
needs: verify-live
runs-on: ubuntu-latest
steps:
- name: Check out repository
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ name: Release
# and reviewed via release PRs on release branches before tagging.
# - This workflow generates the release-specific notes deterministically using
# `git-cliff` and publishes the official GitHub Release for the tag.
# - ISSUE-117: the GitHub Release is only created after the same live
# Tor verification that gates publishing has passed for this tag, so a
# broken image can no longer ship as an official release.

on:
push:
Expand All @@ -19,8 +22,26 @@ permissions:
contents: read

jobs:
verify-live:
name: "Live Image Verification (Pre-Release Gate)"
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run Live Image Verification
uses: ./.github/actions/live-verification
with:
image_ref: "torproxy:smoke-test"
build_local: "true"
run_alerts: "false"
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}

github-release:
name: "Generate Changelog & Publish GitHub Release"
needs: verify-live
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ By default `torproxy` uses the Tor GeoIP database bundled with the Alpine Tor pa
TORPROXY_GEOIP_AUTO_UPDATE=1 TORPROXY_COUNTRY=DE docker compose up -d
```

With `TORPROXY_GEOIP_AUTO_UPDATE=1` the container downloads `geoip`/`geoip6` from Tor Project GitLab (HTTPS) at startup and then every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400` = 24h), validates the format, installs the files atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`), and sends Tor `SIGHUP` to reload them. If the download or validation fails (e.g. offline or upstream unavailable), the previously installed — or bundled — database keeps working, so an old version never breaks country routing. The Daily Live Image Verification workflow checks daily that the GeoIP URLs are reachable and that a container with auto-update enabled downloads the database. Additional settings: `TORPROXY_GEOIP_URL`, `TORPROXY_GEOIP6_URL`, `TORPROXY_GEOIP_MAX_BYTES`.
With `TORPROXY_GEOIP_AUTO_UPDATE=1` the container downloads `geoip`/`geoip6` from Tor Project GitLab (HTTPS) at startup and then every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400` = 24h), validates the format, installs the files atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`), and sends Tor `SIGHUP` to reload them. If the download or validation fails (e.g. offline or upstream unavailable), the previously installed — or bundled — database keeps working, so an old version never breaks country routing. Each attempt is recorded and exposed additively as `geoip` in `/status`, so a silent fallback is visible. The pre-publish Live Image Verification gate checks that the GeoIP URLs are reachable and that a container with auto-update enabled downloads the database; the Daily Live Image Verification workflow repeats this as a drift detector. Additional settings: `TORPROXY_GEOIP_URL`, `TORPROXY_GEOIP6_URL`, `TORPROXY_GEOIP_MAX_BYTES`, `TORPROXY_GEOIP6_MAX_BYTES`.

---

Expand Down
4 changes: 3 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ This document defines the strict, non-negotiable backward compatibility contract
| `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`. |
| `TORPROXY_GEOIP_AUTO_UPDATE` | `0` | Boolean (`0`, `1`, `true`, `false`, `yes`, `no`) | Optional additive: Periodically download Tor GeoIP DB from Tor Project GitLab. Default: `0` (bundled DB, unchanged behavior). |
| `TORPROXY_GEOIP_MAX_BYTES` | `16777216` | Positive integer | Optional additive: Max accepted IPv4 GeoIP payload; also caps IPv6 unless `TORPROXY_GEOIP6_MAX_BYTES` is set (backward compatible). |
| `TORPROXY_GEOIP6_MAX_BYTES` | `33554432` | Positive integer | Optional additive: Max accepted IPv6 GeoIP payload (upstream `geoip6` ≈ 16 MB). |
| `TORPROXY_API_TOKEN` | *(unset)* | String | Optional: Bearer token / API key securing REST API (8080). Default: unset (unauthenticated). |
| `TORPROXY_API_ALLOW_ANONYMOUS_HEALTH` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Permits unauthenticated `/health` probe access when token auth is enabled. |
| `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restricts SOCKS port access by source CIDR (Tor `SocksPolicy`). Default unset = accept all (legacy behavior). |
Expand Down Expand Up @@ -73,7 +75,7 @@ The `status` field must strictly evaluate to one of the following canonical stri
- `"connecting"`: Tor is bootstrapping or initial circuit is not yet ready.
- `"disconnected"`: Network disabled via `/disconnect`.

Optional additive fields such as `detail` may be included, but `status` and `ip` must never be omitted or renamed.
Optional additive fields such as `detail` and `geoip` (GeoIP auto-update outcome: `auto_update`, `source`, `last_attempt_at`, `last_success_at`, `last_error`, `v4_bytes`, `v6_bytes`) may be included, but `status` and `ip` must never be omitted or renamed.

---

Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ All settings have sensible defaults that allow running `torproxy` with zero conf
| `TORPROXY_GEOIP_DIR` | `/var/lib/tor/geoip` | Absolute path | Directory where validated `geoip`/`geoip6` files are installed atomically. |
| `TORPROXY_GEOIP_URL` | Tor Project GitLab `geoip` | `https://` URL | Source URL for the IPv4 GeoIP database. |
| `TORPROXY_GEOIP6_URL` | Tor Project GitLab `geoip6` | `https://` URL | Source URL for the IPv6 GeoIP database. |
| `TORPROXY_GEOIP_MAX_BYTES` | `10485760` | Positive integer | Maximum accepted size per GeoIP file; larger payloads are rejected. |
| `TORPROXY_GEOIP_MAX_BYTES` | `16777216` | Positive integer | Maximum accepted size of the IPv4 GeoIP file; larger payloads are rejected. Setting this also caps the IPv6 file (backward compatibility) unless overridden below. |
| `TORPROXY_GEOIP6_MAX_BYTES` | `33554432` | Positive integer | Maximum accepted size of the IPv6 GeoIP file (upstream `geoip6` is ~16 MB); defaults to `TORPROXY_GEOIP_MAX_BYTES` when that is set explicitly. |
| `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restrict which source networks may use the SOCKS port (Tor `SocksPolicy` — accept listed CIDRs plus loopback, reject all else). Default unset = accept all (legacy behavior). Note: Tor's SocksPort is unauthenticated by design; client username/password is only stream isolation, not a credential check. |

---
Expand Down Expand Up @@ -120,3 +121,5 @@ Dynamic changes are applied via Tor Control Protocol (`SETCONF ExitNodes=...`) a
Enable `TORPROXY_GEOIP_AUTO_UPDATE=1` to download `geoip`/`geoip6` from Tor Project GitLab over HTTPS at startup and every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400`). Downloaded files are format-validated and installed atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`); Tor is then sent `SIGHUP` to reload them (`PidFile /tmp/tor.pid`).

**Fallback guarantee:** if the upstream is unreachable or the payload is invalid, the previously installed database — or, on first start, the bundled `/usr/share/tor/geoip{,6}` — keeps working. The container never fails to start because of GeoIP download problems, and a working database is never overwritten by a broken payload.

**Observability:** every attempt is recorded to `geoip-status.json` in `TORPROXY_GEOIP_DIR` and surfaced additively as the `geoip` object in `/status` (`source` = `downloaded`/`bundled`/`unknown`, `last_attempt_at`, `last_success_at`, `last_error`, `v4_bytes`, `v6_bytes`), so a silent fallback — e.g. an upstream file outgrowing its size limit — is visible without reading container logs.
25 changes: 25 additions & 0 deletions src/torproxy/adapters/geoip_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Filesystem adapter for the GeoIP auto-update status file (ISSUE-117).

The GeoIP updater (``torproxy.runtime.geoip``) runs as a separate
process and records its latest outcome as JSON in the GeoIP dir.
This adapter is the only place that reads that file; core modules
receive the outcome through the ``GeoIpStatus`` port and never do I/O.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any


class FileGeoIpStatus:
def __init__(self, geoip_dir: str) -> None:
self._path = Path(geoip_dir) / "geoip-status.json"

def snapshot(self) -> dict[str, Any] | None:
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
5 changes: 4 additions & 1 deletion src/torproxy/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""The only composition root that selects concrete I/O adapters."""

from torproxy.adapters.clock import SystemClock
from torproxy.adapters.geoip_status import FileGeoIpStatus
from torproxy.adapters.probes import SocksReadinessProbe, TorEgressProbe
from torproxy.adapters.tor_control import TorControlClient
from torproxy.configuration import Settings
from torproxy.ports import Clock, ControlPort, EgressProbe, SocksProbe
from torproxy.ports import Clock, ControlPort, EgressProbe, GeoIpStatus, SocksProbe
from torproxy.service import ProxyService
from torproxy.state import RuntimeState
from torproxy.web.router import Router
Expand All @@ -18,13 +19,15 @@ def create_services(
egress: EgressProbe | None = None,
socks: SocksProbe | None = None,
clock: Clock | None = None,
geoip: GeoIpStatus | None = None,
) -> ProxyService:
return ProxyService(
settings,
control or TorControlClient(settings.control_port, settings.cookie_path),
egress or TorEgressProbe(settings.proxy_port),
socks or SocksReadinessProbe(settings.socks_port),
RuntimeState(settings.country, settings.strict_nodes, clock or SystemClock()),
geoip if geoip is not None else FileGeoIpStatus(settings.geoip_dir),
)


Expand Down
17 changes: 15 additions & 2 deletions src/torproxy/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ class Settings:
geoip_dir: str = "/var/lib/tor/geoip"
geoip_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip"
geoip6_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6"
geoip_max_bytes: int = 10 * 1024 * 1024
# ISSUE-117: per-file limits. The IPv4 and IPv6 upstream
# databases grow independently (geoip6 is ~16 MB, geoip ~10 MB),
# so a single shared cap silently rejected the IPv6 file forever.
geoip_max_bytes: int = 16 * 1024 * 1024
geoip6_max_bytes: int = 32 * 1024 * 1024
# ISSUE-18c (#108): optional comma-separated CIDRs allowed to
# use the SOCKS port (Tor SocksPolicy). Empty = accept all
# (legacy behavior). Tor's SocksPort is unauthenticated by
Expand Down Expand Up @@ -127,6 +131,8 @@ def __post_init__(self) -> None:
raise ValueError("TORPROXY_GEOIP_UPDATE_INTERVAL must be a positive integer")
if self.geoip_max_bytes < 1:
raise ValueError("TORPROXY_GEOIP_MAX_BYTES must be a positive integer")
if self.geoip6_max_bytes < 1:
raise ValueError("TORPROXY_GEOIP6_MAX_BYTES must be a positive integer")
if not self.geoip_dir.startswith("/"):
raise ValueError("TORPROXY_GEOIP_DIR must be an absolute path")
for name, url in (
Expand Down Expand Up @@ -224,7 +230,14 @@ def _int_setting(name: str, default: int) -> int:
"TORPROXY_GEOIP6_URL",
"https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6",
),
geoip_max_bytes=_int_setting("TORPROXY_GEOIP_MAX_BYTES", 10 * 1024 * 1024),
geoip_max_bytes=_int_setting("TORPROXY_GEOIP_MAX_BYTES", 16 * 1024 * 1024),
# Backward compat: the legacy single TORPROXY_GEOIP_MAX_BYTES
# still caps both files unless TORPROXY_GEOIP6_MAX_BYTES
# explicitly overrides the IPv6 limit.
geoip6_max_bytes=_int_setting(
"TORPROXY_GEOIP6_MAX_BYTES",
int(env.get("TORPROXY_GEOIP_MAX_BYTES", str(32 * 1024 * 1024))),
),
socks_allowed_cidrs=env.get("TORPROXY_SOCKS_ALLOWED_CIDRS", "").strip(),
)

Expand Down
7 changes: 6 additions & 1 deletion src/torproxy/ports.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Small structural interfaces; application code does not import adapters."""

from typing import Protocol
from typing import Any, Protocol

from torproxy.models import CommandResult

Expand All @@ -26,3 +26,8 @@ def monotonic(self) -> float:

def time(self) -> float:
"""Return wall-clock epoch time in seconds."""


class GeoIpStatus(Protocol):
def snapshot(self) -> dict[str, Any] | None:
"""Return the latest GeoIP auto-update outcome, or None if unknown."""
29 changes: 27 additions & 2 deletions src/torproxy/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,35 @@ class ProxyQueries:
def __init__(self, service: ProxyService) -> None:
self.service = service

def _geoip_info(self) -> dict[str, Any]:
# ISSUE-117: additive observability so a silent fallback to
# the bundled GeoIP DB (e.g. upstream file outgrew the size
# limit) is visible in /status instead of only in logs.
info: dict[str, Any] = {
"auto_update": self.service.settings.geoip_auto_update,
"source": "unknown",
"last_attempt_at": None,
"last_success_at": None,
"last_error": None,
}
snapshot = self.service.geoip_snapshot()
if snapshot:
for key in ("source", "last_attempt_at", "last_success_at", "last_error"):
if key in snapshot:
info[key] = snapshot[key]
for key in ("v4_bytes", "v6_bytes"):
if isinstance(snapshot.get(key), int):
info[key] = snapshot[key]
return info

def status(self) -> Response:
response, error = self.service.send_tor_command("GETCONF DisableNetwork")
if error:
return Response(500, {"error": f"Failed to query Tor: {error}"})
if "DisableNetwork=0" not in (response or ""):
return Response(200, {"status": "disconnected", "ip": None})
return Response(
200, {"status": "disconnected", "ip": None, "geoip": self._geoip_info()}
)
bootstrap, error = self.service.get_tor_bootstrap_status()
if not bootstrap or not bootstrap["bootstrapped"]:
detail = bootstrap["summary"] if bootstrap else error or "Unknown bootstrap status"
Expand All @@ -32,17 +55,19 @@ def status(self) -> Response:
"status": "connecting",
"ip": None,
"detail": f"Tor bootstrapping: {detail}",
"geoip": self._geoip_info(),
},
)
ip, error = self.service.get_current_ip()
if ip:
return Response(200, {"status": "connected", "ip": ip})
return Response(200, {"status": "connected", "ip": ip, "geoip": self._geoip_info()})
return Response(
200,
{
"status": "connecting",
"ip": None,
"detail": f"Failed to route traffic (Tor bootstrapping?): {error}",
"geoip": self._geoip_info(),
},
)

Expand Down
Loading
Loading